diff --git a/README.md b/README.md index 32859a4..f4ed1e6 100644 --- a/README.md +++ b/README.md @@ -632,11 +632,11 @@ endpoints/my-endpoint/ ### definition.ts — The Contract ```typescript -import type { FilterRegistry } from '@platform/backend/query/schemas.ts' +import type { FilterRegistry } from '#shared/server/query/schemas.ts' import type { EndpointDefinition } from '#shared/server/types.ts' -import { makePaginationResultSchema } from '@platform/backend/response/schemas.ts' -import { createEndpointQuerySchema } from '@platform/backend/query/query.ts' +import { makePaginationResultSchema } from '#shared/response/schemas.ts' +import { createEndpointQuerySchema } from '#shared/server/query/query.ts' import { z } from 'zod' import { CURSOR_SECRET } from './_env.ts' @@ -702,7 +702,7 @@ import type { } from '#shared/server/types.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { paginate } from '@platform/backend/response/index.ts' +import { paginate } from '#shared/response/mod.ts' import Definition from './definition.ts' export type AppEnv = FunctionAppEnv @@ -811,7 +811,7 @@ LIMIT 20 ### Success Responses ```typescript -import { paginate, ok, created } from '@platform/backend/response/index.ts' +import { paginate, ok, created } from '#shared/response/mod.ts' // Collection with pagination return c.json(...paginate(url, items, paginationMeta)) @@ -830,7 +830,7 @@ import { badRequest, notFound, internalServerError -} from '@platform/backend/response/index.ts' +} from '#shared/response/mod.ts' // 400 Bad Request return c.json(...badRequest(path, 'Invalid filter')) diff --git a/deno.jsonc b/deno.jsonc index 8858df7..4c202be 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -4,18 +4,18 @@ ], "nodeModulesDir": "auto", "imports": { - "@supabase/supabase-js": "jsr:@supabase/supabase-js@2", - "hono": "jsr:@hono/hono@4", - "zod": "jsr:@zod/zod@4", - "@standard-schema/spec": "jsr:@standard-schema/spec@1", - "@standard-schema/utils": "jsr:@standard-schema/utils@0", - "@hono/standard-validator": "jsr:@hono/standard-validator@0", - "hono-rate-limiter": "jsr:@hono-rate-limiter/hono-rate-limiter@0", - "@hono-rate-limiter/redis": "jsr:@hono-rate-limiter/redis@0", - "@hono/otel": "jsr:@hono/otel@1", + "@supabase/supabase-js": "jsr:@supabase/supabase-js@^2.91.0", + "hono": "jsr:@hono/hono@^4.11.4", + "zod": "jsr:@zod/zod@^4.3.5", + "@standard-schema/spec": "jsr:@standard-schema/spec@^1.1.0", + "@standard-schema/utils": "jsr:@standard-schema/utils@^0.3.0", + "@hono/standard-validator": "jsr:@hono/standard-validator@^0.2.2", + "hono-rate-limiter": "jsr:@hono-rate-limiter/hono-rate-limiter@^0.4.2", + "@hono-rate-limiter/redis": "jsr:@hono-rate-limiter/redis@^0.1.4", + "@hono/otel": "jsr:@hono/otel@^1.1.0", "#shared/": "./supabase/functions/_shared/", "@platform/shared": "./packages/shared/src/", - "@platform/backend/": "./packages/backend/src/" + "#shared/server/": "./packages/backend/src/" }, "compilerOptions": { "lib": [ diff --git a/deno.lock b/deno.lock index 609ac10..29c38f9 100644 --- a/deno.lock +++ b/deno.lock @@ -1,51 +1,87 @@ { "version": "5", "specifiers": { + "jsr:@cspotcode/outdent@0": "0.8.0", + "jsr:@cspotcode/outdent@0.8": "0.8.0", "jsr:@deno-library/compress@~0.5.5": "0.5.6", "jsr:@deno-library/crc32@1.0.2": "1.0.2", "jsr:@hono-rate-limiter/hono-rate-limiter@0": "0.4.2", "jsr:@hono-rate-limiter/hono-rate-limiter@~0.4.2": "0.4.2", "jsr:@hono-rate-limiter/redis@0": "0.1.4", - "jsr:@hono/hono@4": "4.10.6", - "jsr:@hono/hono@^4.6.12": "4.10.6", - "jsr:@hono/hono@^4.8.3": "4.10.6", - "jsr:@hono/otel@1": "1.0.1", - "jsr:@hono/standard-validator@0": "0.2.0", - "jsr:@standard-schema/spec@1": "1.0.0", + "jsr:@hono-rate-limiter/redis@~0.1.4": "0.1.4", + "jsr:@hono/hono@4": "4.11.4", + "jsr:@hono/hono@^4.11.4": "4.11.4", + "jsr:@hono/hono@^4.6.12": "4.11.4", + "jsr:@hono/hono@^4.8.3": "4.11.4", + "jsr:@hono/otel@1": "1.1.0", + "jsr:@hono/otel@^1.1.0": "1.1.0", + "jsr:@hono/standard-validator@0": "0.2.2", + "jsr:@hono/standard-validator@~0.2.2": "0.2.2", + "jsr:@logtape/hono@2": "2.0.1", + "jsr:@logtape/logtape@2": "2.0.1", + "jsr:@logtape/logtape@^2.0.1": "2.0.1", + "jsr:@logtape/pretty@2": "2.0.1", + "jsr:@okikio/sparql@0": "0.0.2", + "jsr:@standard-schema/spec@1": "1.1.0", + "jsr:@standard-schema/spec@^1.1.0": "1.1.0", "jsr:@standard-schema/utils@0": "0.3.0", + "jsr:@standard-schema/utils@0.3": "0.3.0", + "jsr:@std/assert@^1.0.14": "1.0.17", + "jsr:@std/assert@^1.0.17": "1.0.17", + "jsr:@std/async@^1.1.0": "1.1.0", "jsr:@std/bytes@^1.0.2": "1.0.6", "jsr:@std/bytes@^1.0.6": "1.0.6", + "jsr:@std/data-structures@^1.0.10": "1.0.10", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/expect@1": "1.0.17", "jsr:@std/fs@1.0.5": "1.0.5", + "jsr:@std/fs@^1.0.22": "1.0.22", + "jsr:@std/internal@^1.0.10": "1.0.12", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/io@0.225.0": "0.225.0", "jsr:@std/path@1.0.8": "1.0.8", "jsr:@std/path@^1.0.7": "1.1.3", "jsr:@std/path@^1.0.8": "1.1.3", + "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/streams@^1.0.14": "1.0.14", "jsr:@std/streams@^1.0.7": "1.0.14", "jsr:@std/tar@0.1.3": "0.1.3", - "jsr:@supabase/supabase-js@2": "2.58.0", + "jsr:@std/testing@1": "1.0.17", + "jsr:@supabase/functions-js@^0.0.0-automated": "0.0.0-jsr-test.1", + "jsr:@supabase/supabase-js@2": "2.91.0", + "jsr:@supabase/supabase-js@^2.91.0": "2.91.0", "jsr:@zip-js/zip-js@2.7.53": "2.7.53", - "jsr:@zod/zod@4": "4.1.12", + "jsr:@zod/zod@4": "4.3.5", + "jsr:@zod/zod@^4.3.5": "4.3.5", "npm:@jsr/standard-schema__spec@1.0.0-beta.4": "1.0.0-beta.4", "npm:@opentelemetry/api@^1.9.0": "1.9.0", "npm:@opentelemetry/semantic-conventions@^1.28.0": "1.38.0", "npm:@supabase/auth-js@2.72.0": "2.72.0", + "npm:@supabase/auth-js@2.91.0": "2.91.0", "npm:@supabase/functions-js@2.5.0": "2.5.0", "npm:@supabase/node-fetch@2.6.15": "2.6.15", "npm:@supabase/postgrest-js@1.21.4": "1.21.4", + "npm:@supabase/postgrest-js@2.91.0": "2.91.0", "npm:@supabase/realtime-js@2.15.5": "2.15.5", + "npm:@supabase/realtime-js@2.91.0": "2.91.0", "npm:@supabase/storage-js@2.12.2": "2.12.2", - "npm:@types/node@*": "24.2.0", - "npm:supabase@^2.62.10": "2.62.10", + "npm:@supabase/storage-js@2.91.0": "2.91.0", + "npm:@types/n3@^1.26.0": "1.26.1", + "npm:@types/node@*": "24.0.7", + "npm:@types/node@24.0.7": "24.0.7", + "npm:n3@^1.26.0": "1.26.0", + "npm:supabase@^2.72.8": "2.72.8", "npm:typescript@^5.9.3": "5.9.3" }, "jsr": { + "@cspotcode/outdent@0.8.0": { + "integrity": "bbb3dea1443b4191a091644dfeaf3f182cca83e9003d211c50786c21792695f6" + }, "@deno-library/compress@0.5.6": { "integrity": "9d76e37e7682fc8d3d99d5641a7af454ce4689b1df3fd3062141a1deb64453cd", "dependencies": [ "jsr:@deno-library/crc32", - "jsr:@std/fs", + "jsr:@std/fs@1.0.5", "jsr:@std/io", "jsr:@std/path@1.0.8", "jsr:@std/tar", @@ -71,6 +107,9 @@ "@hono/hono@4.10.6": { "integrity": "bcbbf9753a3570f2fa4609484bae0cead85a3feb110ee9ceb7ffc37d845d9a4d" }, + "@hono/hono@4.11.4": { + "integrity": "aaf7b9d5a6b2422b0778c091b712ee1f018bc7e82138067d21eb27d7c2e1f5be" + }, "@hono/otel@1.0.1": { "integrity": "b2531a36716d9c410ece99a51acd3b94c729880100ed9113f45a8fe86d3af27c", "dependencies": [ @@ -79,31 +118,102 @@ "npm:@opentelemetry/semantic-conventions" ] }, + "@hono/otel@1.1.0": { + "integrity": "f715e91da99c435b45519af9a13675b58fa37beaec3b4d6ebfb9ae8ebe908e08", + "dependencies": [ + "jsr:@hono/hono@^4.8.3", + "npm:@opentelemetry/api", + "npm:@opentelemetry/semantic-conventions" + ] + }, "@hono/standard-validator@0.2.0": { "integrity": "3ba16798a99c6124f8f44608c80b82962711105107e561780a7894b648e28929", "dependencies": [ "jsr:@hono/hono@^4.8.3", - "jsr:@standard-schema/spec" + "jsr:@standard-schema/spec@1" + ] + }, + "@hono/standard-validator@0.2.2": { + "integrity": "bc94e1ab41d677a571cb6dd5012823f1162b9856ca24dfd60233734824bb0b0c", + "dependencies": [ + "jsr:@hono/hono@^4.8.3", + "jsr:@standard-schema/spec@1" + ] + }, + "@logtape/hono@2.0.1": { + "integrity": "f1c8bdb6d16cbee812dac44d17b500e01a81f32a2b8d7cce1afc64d5099e4bdb", + "dependencies": [ + "jsr:@hono/hono@4", + "jsr:@logtape/logtape@^2.0.1" + ] + }, + "@logtape/logtape@2.0.1": { + "integrity": "cbc40fa7f6f2fb9ffe0a717a7c87a0be285c0ab385f416e39f8ba62cab1a1c7f" + }, + "@logtape/pretty@2.0.1": { + "integrity": "d8fd0090244a9e26a90abc29f1f703c498d64b97331d5455dde529ddfc44ff75", + "dependencies": [ + "jsr:@logtape/logtape@^2.0.1", + "npm:@types/node@24.0.7" + ] + }, + "@okikio/sparql@0.0.2": { + "integrity": "1ffbe5eba09152ba9a16e8b040b0fb1a39a8e4b8749d7cc10f9d16bec23c1b9e", + "dependencies": [ + "jsr:@cspotcode/outdent@0.8", + "npm:@types/n3", + "npm:n3" ] }, "@standard-schema/spec@1.0.0": { "integrity": "4f20bbcf34e92b92f8c01589b958abc7c87385fa9a96170cecdc643d4d5737c0" }, + "@standard-schema/spec@1.1.0": { + "integrity": "2ccd54513cd9c960bd155ab569b1a901bc99c6f9ad29559d3f38a28c91c1822d" + }, "@standard-schema/utils@0.3.0": { "integrity": "f62e134baac4f1d074059c78a5d68974ed19d1ce42cbcb96edb4c11c39ccd1cf", "dependencies": [ "npm:@jsr/standard-schema__spec" ] }, + "@std/assert@1.0.17": { + "integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/async@1.1.0": { + "integrity": "72418df08d1be84668a53e48aab3520d68ae6882182f8a5ca75c6d1f087220d1" + }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, + "@std/data-structures@1.0.10": { + "integrity": "f574f86b0e07c69b9edc555fcc814b57d29258bad39fd5a34ba8a80ecf033cfe" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/expect@1.0.17": { + "integrity": "316b47dd65c33e3151344eb3267bf42efba17d1415425f07ed96185d67fc04d9", + "dependencies": [ + "jsr:@std/assert@^1.0.14", + "jsr:@std/internal@^1.0.10" + ] + }, "@std/fs@1.0.5": { "integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e", "dependencies": [ "jsr:@std/path@^1.0.7" ] }, + "@std/fs@1.0.22": { + "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308", + "dependencies": [ + "jsr:@std/path@^1.1.4" + ] + }, "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, @@ -119,7 +229,13 @@ "@std/path@1.1.3": { "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal@^1.0.12" ] }, "@std/streams@1.0.14": { @@ -134,15 +250,39 @@ "jsr:@std/streams@^1.0.7" ] }, + "@std/testing@1.0.17": { + "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", + "dependencies": [ + "jsr:@std/assert@^1.0.17", + "jsr:@std/async", + "jsr:@std/data-structures", + "jsr:@std/fs@^1.0.22", + "jsr:@std/internal@^1.0.12", + "jsr:@std/path@^1.1.4" + ] + }, + "@supabase/functions-js@0.0.0-jsr-test.1": { + "integrity": "b1b37924af1f0011b4753201e1585a37b030e996c6682492e02575eb27d902b2" + }, "@supabase/supabase-js@2.58.0": { "integrity": "4d04e72e9f632b451ac7d1a84de0b85249c0097fdf06253f371c1f0a23e62c87", "dependencies": [ - "npm:@supabase/auth-js", - "npm:@supabase/functions-js", + "npm:@supabase/auth-js@2.72.0", + "npm:@supabase/functions-js@2.5.0", "npm:@supabase/node-fetch", - "npm:@supabase/postgrest-js", - "npm:@supabase/realtime-js", - "npm:@supabase/storage-js" + "npm:@supabase/postgrest-js@1.21.4", + "npm:@supabase/realtime-js@2.15.5", + "npm:@supabase/storage-js@2.12.2" + ] + }, + "@supabase/supabase-js@2.91.0": { + "integrity": "e7f1aa393cf38cb92b8a76350234f1f59c7f47eeb1198380a621d8cfff4b4d3b", + "dependencies": [ + "jsr:@supabase/functions-js@^0.0.0-automated", + "npm:@supabase/auth-js@2.91.0", + "npm:@supabase/postgrest-js@2.91.0", + "npm:@supabase/realtime-js@2.91.0", + "npm:@supabase/storage-js@2.91.0" ] }, "@zip-js/zip-js@2.7.53": { @@ -150,6 +290,9 @@ }, "@zod/zod@4.1.12": { "integrity": "5876ed4c6d44673faf5120f0a461a2ada2eb6c735329d3ebaf5ba1fc08387695" + }, + "@zod/zod@4.3.5": { + "integrity": "3d14553f025d6e3d1a836b9c56e366bbce27d942301e8c7c1ac4c365df5f8085" } }, "npm": { @@ -169,12 +312,24 @@ "@opentelemetry/semantic-conventions@1.38.0": { "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==" }, + "@rdfjs/types@2.0.1": { + "integrity": "sha512-uyAzpugX7KekAXAHq26m3JlUIZJOC0uSBhpnefGV5i15bevDyyejoB7I+9MKeUrzXD8OOUI3+4FeV1wwQr5ihA==", + "dependencies": [ + "@types/node" + ] + }, "@supabase/auth-js@2.72.0": { "integrity": "sha512-4+bnUrtTDK1YD0/FCx2YtMiQH5FGu9Jlf4IQi5kcqRwRwqp2ey39V61nHNdH86jm3DIzz0aZKiWfTW8qXk1swQ==", "dependencies": [ "@supabase/node-fetch" ] }, + "@supabase/auth-js@2.91.0": { + "integrity": "sha512-9ywvsKLsxTwv7fvN5fXzP3UfRreqrX2waylTBDu0lkmeHXa8WtSQS9e0WV9FBduiazYqQbgfBQXBNPRPsRgWOQ==", + "dependencies": [ + "tslib" + ] + }, "@supabase/functions-js@2.5.0": { "integrity": "sha512-SXBx6Jvp+MOBekeKFu+G11YLYPeVeGQl23eYyAG9+Ro0pQ1aIP0UZNIBxHKNHqxzR0L0n6gysNr2KT3841NATw==", "dependencies": [ @@ -193,6 +348,12 @@ "@supabase/node-fetch" ] }, + "@supabase/postgrest-js@2.91.0": { + "integrity": "sha512-5S41zv2euNpGucvtM4Wy+xOmLznqt/XO+Lh823LOFEQ00ov7QJfvqb6VzIxufvzhooZpmGR0BxvMcJtWxCIFdQ==", + "dependencies": [ + "tslib" + ] + }, "@supabase/realtime-js@2.15.5": { "integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==", "dependencies": [ @@ -202,14 +363,37 @@ "ws" ] }, + "@supabase/realtime-js@2.91.0": { + "integrity": "sha512-u2YuJFG35umw8DO9beC27L/jYXm3KhF+73WQwbynMpV0tXsFIA0DOGRM0NgRyy03hJIdO6mxTTwe8efW3yx3Tg==", + "dependencies": [ + "@types/phoenix", + "@types/ws", + "tslib", + "ws" + ] + }, "@supabase/storage-js@2.12.2": { "integrity": "sha512-SiySHxi3q7gia7NBYpsYRu8gyI0NhFwSORMxbZIxJ/zAVkN6QpwDRan158CJ+UdzD4WB/rQMAGRqIJQP+7ccAQ==", "dependencies": [ "@supabase/node-fetch" ] }, - "@types/node@24.2.0": { - "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "@supabase/storage-js@2.91.0": { + "integrity": "sha512-CI7fsVIBQHfNObqU9kmyQ1GWr+Ug44y4rSpvxT4LdQB9tlhg1NTBov6z7Dlmt8d6lGi/8a9lf/epCDxyWI792g==", + "dependencies": [ + "iceberg-js", + "tslib" + ] + }, + "@types/n3@1.26.1": { + "integrity": "sha512-TilYHzpU6ecXVJAbV+6o17Z8ZkWLWx6ZJD3IluaU4RiGHxqjU2or9fopxFHS6iXS6qcl5Mg1K3wSx9L8xxJaJQ==", + "dependencies": [ + "@rdfjs/types", + "@types/node" + ] + }, + "@types/node@24.0.7": { + "integrity": "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw==", "dependencies": [ "undici-types" ] @@ -223,9 +407,18 @@ "@types/node" ] }, + "abort-controller@3.0.0": { + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": [ + "event-target-shim" + ] + }, "agent-base@7.1.4": { "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "bin-links@6.0.0": { "integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==", "dependencies": [ @@ -236,6 +429,13 @@ "write-file-atomic" ] }, + "buffer@6.0.3": { + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, "chownr@3.0.0": { "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==" }, @@ -251,6 +451,12 @@ "ms" ] }, + "event-target-shim@5.0.1": { + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, + "events@3.3.0": { + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" + }, "fetch-blob@3.2.0": { "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "dependencies": [ @@ -271,6 +477,12 @@ "debug" ] }, + "iceberg-js@0.8.1": { + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==" + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, "imurmurhash@0.1.4": { "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==" }, @@ -286,6 +498,13 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "n3@1.26.0": { + "integrity": "sha512-SQknS0ua90rN+3RHuk8BeIqeYyqIH/+ecViZxX08jR4j6MugqWRjtONl3uANG/crWXnOM2WIqBJtjIhVYFha+w==", + "dependencies": [ + "buffer", + "readable-stream" + ] + }, "node-domexception@1.0.0": { "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": true @@ -304,14 +523,36 @@ "proc-log@6.1.0": { "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==" }, + "process@0.11.10": { + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" + }, "read-cmd-shim@6.0.0": { "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==" }, + "readable-stream@4.7.0": { + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dependencies": [ + "abort-controller", + "buffer", + "events", + "process", + "string_decoder" + ] + }, + "safe-buffer@5.2.1": { + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, "signal-exit@4.1.0": { "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" }, - "supabase@2.62.10": { - "integrity": "sha512-Zw5vDl+3KGU8VWSChLQqJ0IYfeLwLNnmW32iS54GVx52VEnOxtDBZylMx/GRGRtv5z6RtU3/Eal7H2B0S1kDcQ==", + "string_decoder@1.3.0": { + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": [ + "safe-buffer" + ] + }, + "supabase@2.72.8": { + "integrity": "sha512-3Wymv/QjmndLB9ACQA31VvJ7+KXmDqj7s8g7y+ldAcCaHBMbj+I7x0j/UBGkNbtSh0BG7kRicGA3Xc3jQlccNQ==", "dependencies": [ "bin-links", "https-proxy-agent", @@ -321,25 +562,29 @@ "scripts": true, "bin": true }, - "tar@7.5.2": { - "integrity": "sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==", + "tar@7.5.3": { + "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", "dependencies": [ "@isaacs/fs-minipass", "chownr", "minipass", "minizlib", "yallist" - ] + ], + "deprecated": true }, "tr46@0.0.3": { "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "typescript@5.9.3": { "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "bin": true }, - "undici-types@7.10.0": { - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" + "undici-types@7.8.0": { + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==" }, "web-streams-polyfill@3.3.3": { "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" @@ -370,34 +615,43 @@ }, "workspace": { "dependencies": [ - "jsr:@hono-rate-limiter/hono-rate-limiter@0", - "jsr:@hono-rate-limiter/redis@0", - "jsr:@hono/hono@4", - "jsr:@hono/otel@1", - "jsr:@hono/standard-validator@0", - "jsr:@standard-schema/spec@1", - "jsr:@standard-schema/utils@0", - "jsr:@supabase/supabase-js@2", - "jsr:@zod/zod@4" + "jsr:@hono-rate-limiter/hono-rate-limiter@~0.4.2", + "jsr:@hono-rate-limiter/redis@~0.1.4", + "jsr:@hono/hono@^4.11.4", + "jsr:@hono/otel@^1.1.0", + "jsr:@hono/standard-validator@~0.2.2", + "jsr:@standard-schema/spec@^1.1.0", + "jsr:@standard-schema/utils@0.3", + "jsr:@supabase/supabase-js@^2.91.0", + "jsr:@zod/zod@^4.3.5" ], "packageJson": { "dependencies": [ - "npm:supabase@^2.62.10", + "npm:supabase@^2.72.8", "npm:typescript@^5.9.3" ] }, "members": { "supabase/functions/_shared": { "dependencies": [ + "jsr:@cspotcode/outdent@0", "jsr:@hono-rate-limiter/hono-rate-limiter@0", "jsr:@hono-rate-limiter/redis@0", "jsr:@hono/hono@4", "jsr:@hono/otel@1", "jsr:@hono/standard-validator@0", + "jsr:@logtape/hono@2", + "jsr:@logtape/logtape@2", + "jsr:@logtape/pretty@2", + "jsr:@okikio/sparql@0", "jsr:@standard-schema/spec@1", "jsr:@standard-schema/utils@0", + "jsr:@std/encoding@1", + "jsr:@std/expect@1", + "jsr:@std/testing@1", "jsr:@supabase/supabase-js@2", - "jsr:@zod/zod@4" + "jsr:@zod/zod@4", + "npm:@types/node@*" ] }, "supabase/functions/social": { diff --git a/package.json b/package.json index 96ed203..59f49c0 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,11 @@ "start:supabase": "supabase start && pnpm run:functions", "info:supabase": "supabase status", "run:functions": "supabase functions serve --env-file .env.local", - "db:types": "supabase gen types typescript --local > packages/shared/src/types/database.types.ts", + "db:types": "supabase gen types typescript --local > supabase/functions/_shared/types/database.ts", "stop:supabase": "supabase stop" }, "devDependencies": { - "supabase": "^2.62.10", + "supabase": "^2.72.8", "typescript": "^5.9.3" }, "pnpm": { diff --git a/packages/backend/.npmrc b/packages/backend/.npmrc deleted file mode 100644 index 406ba93..0000000 --- a/packages/backend/.npmrc +++ /dev/null @@ -1,4 +0,0 @@ -# Configuration for private npm package dependencies -# For more information on using private registries with Edge Functions, see: -# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries -@jsr:registry=https://npm.jsr.io \ No newline at end of file diff --git a/packages/backend/package.json b/packages/backend/package.json deleted file mode 100644 index 6109b41..0000000 --- a/packages/backend/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@platform/backend", - "type": "module", - "exports": { - "./query/*": "./src/query/*", - "./response/*": "./src/response/*", - "./query": "./src/query/index.ts", - "./response": "./src/utils/response.ts" - }, - "typesVersions": { - "*": { - "query/*": [ - "src/query/*" - ], - "response/*": [ - "src/response/*" - ] - } - }, - "dependencies": { - "@standard-schema/spec": "^1", - "@standard-schema/utils": "^0", - "@platform/shared": "workspace:^", - "hono": "^4", - "zod": "^4" - }, - "devDependencies": { - "@types/node": "^24.10.1" - } -} \ No newline at end of file diff --git a/packages/backend/src/endpoint/definitions.ts b/packages/backend/src/endpoint/definitions.ts deleted file mode 100644 index 62f9659..0000000 --- a/packages/backend/src/endpoint/definitions.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ValidationTargets } from 'hono' -import type { FormValue, ParsedFormValue } from 'hono/types' -import type z from 'zod' - -/** - * Schema accepting input type I, producing any output - * - * No structural constraints - accepts: - * - Plain objects: z.object({ ... }) - * - Pipes: z.object({ ... }).pipe(...) - * - Transforms: z.object({ ... }).transform(...) - * - Unions, intersections, etc. - * - * Use this when you need input type safety but want to allow transformations. - */ -export type SchemaFor = z.ZodType - -/** - * Helper to normalize record types for compatibility checking - * - * Converts Record to { [key: string]: V } to allow index signature - * schemas to satisfy specific key union requirements. - * - * This enables: z.object({}).catchall(z.string()) to satisfy - * RecordSchemaFor> - */ -type NormalizedRecordInput = - T extends Record - ? K extends string - ? { [key: string]: V } - : T - : T - -/** - * Schema with record-shaped input - * - * Accepts schemas with index signatures that can parse the expected record type. - * This allows catchall schemas to satisfy specific key union requirements. - * - * @example - * // ✓ This works - catchall accepts any string keys - * const schema = z.object({}).catchall(z.string()) - * satisfies RecordSchemaFor> - */ -export type RecordSchemaFor, Output = any> = - SchemaFor, Output> - -/** - * @deprecated Use SchemaFor instead - allows transformations - */ -export type AnySchemaFor = SchemaFor - -/** - * Validation schemas for each input source - * - * Each schema must accept the raw input type from its source: - * - Query: Record - * - Form: Record - * - Json: any - * - Param: Record - * - Header: Record - * - Cookie: Record - * - * Schemas can transform to any output type (pipes/transforms allowed). - */ -export type EndpointDefinitionSchemas = { - [K in keyof ValidationTargets as Capitalize]?: - ValidationTargets[K] extends Record - ? RecordSchemaFor[K]> - : SchemaFor[K]> -} - -// Endpoint definition contract -export type EndpointDefinition = { - Name: string - Route: string - Description?: string - Methods: readonly ('GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH')[] - Input: z.ZodType - Output: z.ZodType - Schemas: EndpointDefinitionSchemas -} diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json deleted file mode 100644 index 951b095..0000000 --- a/packages/backend/tsconfig.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - // Enable top-level await, and other modern ESM features. - "target": "ESNext", - "module": "ESNext", - // Enable module resolution without file extensions on relative paths, for things like npm package imports. - "moduleResolution": "Bundler", - // Allow importing TypeScript files using their native extension (.ts(x)). - "allowImportingTsExtensions": true, - // Enable JSON imports. - "resolveJsonModule": true, - // Enforce the usage of type-only imports when needed, which helps avoiding bundling issues. - "verbatimModuleSyntax": true, - // Ensure that each file can be transpiled without relying on other imports. - // This is redundant with the previous option, however it ensures that it's on even if someone disable `verbatimModuleSyntax` - "isolatedModules": true, - // Astro directly run TypeScript code, no transpilation needed. - "noEmit": true, - // Report an error when importing a file using a casing different from another import of the same file. - "forceConsistentCasingInFileNames": true, - // Properly support importing CJS modules in ESM - "esModuleInterop": true, - // Skip typechecking libraries and .d.ts files - "skipLibCheck": true, - // Allow JavaScript files to be imported - "allowJs": true, - // Allow JSX files (or files that are internally considered JSX, like Astro files) to be imported inside `.js` and `.ts` files. - "jsx": "react-jsx", - // Enable strict type-checking options. - "strict": true, - // Enable incremental compilation for faster subsequent builds. - "incremental": true, - // Specify the library files to be included in the compilation. - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.jsx" -, -"../../supabase/functions/_shared/supabase.ts" -], -} \ No newline at end of file diff --git a/packages/shared/.npmrc b/packages/shared/.npmrc deleted file mode 100644 index 691d217..0000000 --- a/packages/shared/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@jsr:registry=https://npm.jsr.io \ No newline at end of file diff --git a/packages/shared/package.json b/packages/shared/package.json deleted file mode 100644 index b961e43..0000000 --- a/packages/shared/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@platform/shared", - "type": "module", - "exports": { - "./types/*": "./src/types/*", - "./utils/*": "./src/utils/*", - "./types": "./src/types/index.ts", - "./utils": "./src/utils/index.ts" - }, - "typesVersions": { - "*": { - "types/*": [ - "src/types/*" - ], - "utils/*": [ - "src/utils/*" - ] - } - }, - "devDependencies": { - "@types/node": "^24.10.1" - } -} \ No newline at end of file diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts deleted file mode 100644 index f90d78b..0000000 --- a/packages/shared/src/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type * from './database.types.ts' diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts deleted file mode 100644 index b2dfa66..0000000 --- a/packages/shared/src/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./config.ts"; -export * from "./env.ts"; \ No newline at end of file diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json deleted file mode 100644 index 410d49e..0000000 --- a/packages/shared/tsconfig.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - // Enable top-level await, and other modern ESM features. - "target": "ESNext", - "module": "ESNext", - // Enable module resolution without file extensions on relative paths, for things like npm package imports. - "moduleResolution": "Bundler", - // Allow importing TypeScript files using their native extension (.ts(x)). - "allowImportingTsExtensions": true, - // Enable JSON imports. - "resolveJsonModule": true, - // Enforce the usage of type-only imports when needed, which helps avoiding bundling issues. - "verbatimModuleSyntax": true, - // Ensure that each file can be transpiled without relying on other imports. - // This is redundant with the previous option, however it ensures that it's on even if someone disable `verbatimModuleSyntax` - "isolatedModules": true, - // Astro directly run TypeScript code, no transpilation needed. - "noEmit": true, - // Report an error when importing a file using a casing different from another import of the same file. - "forceConsistentCasingInFileNames": true, - // Properly support importing CJS modules in ESM - "esModuleInterop": true, - // Skip typechecking libraries and .d.ts files - "skipLibCheck": true, - // Allow JavaScript files to be imported - "allowJs": true, - // Allow JSX files (or files that are internally considered JSX, like Astro files) to be imported inside `.js` and `.ts` files. - "jsx": "react-jsx", - // Enable strict type-checking options. - "strict": true, - // Enable incremental compilation for faster subsequent builds. - "incremental": true, - // Specify the library files to be included in the compilation. - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.jsx" - ], -} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e12a950..30bd480 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,55 +9,18 @@ importers: .: devDependencies: supabase: - specifier: ^2.62.10 - version: 2.63.1 + specifier: ^2.72.8 + version: 2.72.8 typescript: specifier: ^5.9.3 version: 5.9.3 - packages/backend: - dependencies: - '@platform/shared': - specifier: workspace:^ - version: link:../shared - '@standard-schema/spec': - specifier: ^1 - version: 1.0.0 - '@standard-schema/utils': - specifier: ^0 - version: 0.3.0 - hono: - specifier: ^4 - version: 4.10.7 - zod: - specifier: ^4 - version: 4.1.13 - devDependencies: - '@types/node': - specifier: ^24.10.1 - version: 24.10.1 - - packages/shared: - devDependencies: - '@types/node': - specifier: ^24.10.1 - version: 24.10.1 - packages: '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - - '@standard-schema/utils@0.3.0': - resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} - agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -95,10 +58,6 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} - hono@4.10.7: - resolution: {integrity: sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw==} - engines: {node: '>=16.9.0'} - https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -143,23 +102,21 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - supabase@2.63.1: - resolution: {integrity: sha512-VptpJrw8sSG8xE6Pda/jg5Vg2f3jt15nJZ0crPvRKGpYkMJi7EjPapbOonRxIH5JvTd98x+lshsLnKR2mkoTRA==} + supabase@2.72.8: + resolution: {integrity: sha512-3Wymv/QjmndLB9ACQA31VvJ7+KXmDqj7s8g7y+ldAcCaHBMbj+I7x0j/UBGkNbtSh0BG7kRicGA3Xc3jQlccNQ==} engines: {npm: '>=8'} hasBin: true - tar@7.5.2: - resolution: {integrity: sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==} + tar@7.5.3: + resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true - undici-types@7.16.0: - resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -172,23 +129,12 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - zod@4.1.13: - resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} - snapshots: '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 - '@standard-schema/spec@1.0.0': {} - - '@standard-schema/utils@0.3.0': {} - - '@types/node@24.10.1': - dependencies: - undici-types: 7.16.0 - agent-base@7.1.4: {} bin-links@6.0.0: @@ -218,8 +164,6 @@ snapshots: dependencies: fetch-blob: 3.2.0 - hono@4.10.7: {} - https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -253,16 +197,16 @@ snapshots: signal-exit@4.1.0: {} - supabase@2.63.1: + supabase@2.72.8: dependencies: bin-links: 6.0.0 https-proxy-agent: 7.0.6 node-fetch: 3.3.2 - tar: 7.5.2 + tar: 7.5.3 transitivePeerDependencies: - supports-color - tar@7.5.2: + tar@7.5.3: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -272,8 +216,6 @@ snapshots: typescript@5.9.3: {} - undici-types@7.16.0: {} - web-streams-polyfill@3.3.3: {} write-file-atomic@7.0.0: @@ -282,5 +224,3 @@ snapshots: signal-exit: 4.1.0 yallist@5.0.0: {} - - zod@4.1.13: {} diff --git a/supabase/functions/_shared/deno.json b/supabase/functions/_shared/deno.jsonc similarity index 58% rename from supabase/functions/_shared/deno.json rename to supabase/functions/_shared/deno.jsonc index fd21f4b..630b834 100644 --- a/supabase/functions/_shared/deno.json +++ b/supabase/functions/_shared/deno.jsonc @@ -2,9 +2,18 @@ "name": "_shared", "version": "1.0.0", "exports": {}, - "nodeModulesDir": "auto", "imports": { + "@okikio/sparql/": "jsr:/@okikio/sparql@^0/", + "@okikio/sparql": "jsr:@okikio/sparql@^0", + "@std/expect": "jsr:@std/expect@1", + "@std/testing": "jsr:@std/testing@1", + "@std/encoding": "jsr:@std/encoding@1", + "outdent": "jsr:@cspotcode/outdent@0", "@supabase/supabase-js": "jsr:@supabase/supabase-js@2", + "@logtape/pretty": "jsr:@logtape/pretty@2", + "@logtape/logtape": "jsr:@logtape/logtape@2", + "@logtape/hono": "jsr:@logtape/hono@2", + "hono/": "jsr:/@hono/hono@4/", "hono": "jsr:@hono/hono@4", "zod": "jsr:@zod/zod@4", "@standard-schema/spec": "jsr:@standard-schema/spec@1", @@ -13,14 +22,15 @@ "hono-rate-limiter": "jsr:@hono-rate-limiter/hono-rate-limiter@0", "@hono-rate-limiter/redis": "jsr:@hono-rate-limiter/redis@0", "@hono/otel": "jsr:@hono/otel@1", - "#shared/": "../_shared/", - "@platform/shared/": "../../../packages/shared/src/", - "@platform/backend/": "../../../packages/backend/src/" + "#shared/": "./" }, "compilerOptions": { "lib": [ "deno.window" ], + "types": [ + "npm:@types/node" + ], "strict": true }, "fmt": { diff --git a/supabase/functions/_shared/execution/mod.ts b/supabase/functions/_shared/execution/mod.ts new file mode 100644 index 0000000..4cced0e --- /dev/null +++ b/supabase/functions/_shared/execution/mod.ts @@ -0,0 +1,2 @@ +export * from './supabase.ts' +export * as sparql from './sparql.ts' \ No newline at end of file diff --git a/supabase/functions/_shared/execution/sparql.ts b/supabase/functions/_shared/execution/sparql.ts new file mode 100644 index 0000000..e54d9a2 --- /dev/null +++ b/supabase/functions/_shared/execution/sparql.ts @@ -0,0 +1,544 @@ +/** + * Generic SPARQL query execution utility. + * + * Mirrors the Supabase execution architecture for consistency across data sources. + * Provides composable query building with proper type safety using @okikio/sparql. + */ + +import type { QuerySpec, FilterOperator, SortDirection } from '../query/schemas.ts' +import { + type select, + triple, + v, + raw, + type SparqlValue, + type TripleSubject, + type TriplePredicate, + type TripleObject, +} from '@okikio/sparql' +import { + executeSparql, + transformResults, + QueryError, + type QueryResult, + type BindingMap, +} from '@okikio/sparql/executor' + +import type { ErrorResult } from '../response/schemas.ts' +import { + badGateway, + badRequest, + gatewayTimeout, + rateLimitExceeded, + requestTimeout, + serviceUnavailable, +} from '../response/errors.ts' + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Base triple pattern for security/scoping (applied before QuerySpec filters). + */ +interface BaseSparqlPattern { + subject: TripleSubject + predicate: TriplePredicate + object: TripleObject +} + +/** + * Options for SPARQL query execution. + */ +interface SparqlQueryOptions { + /** SPARQL endpoint URL */ + endpoint: string + /** Base patterns for security/scoping (e.g., tenant isolation, soft deletes) */ + basePatterns?: BaseSparqlPattern[] + /** Query timeout in milliseconds (default: 30000) */ + timeout?: number + /** Additional namespace prefixes beyond standard ones */ + additionalPrefixes?: Record +} + +/** + * Discriminated error types for proper error handling. + */ +type SparqlErrorKind = 'syntax' | 'timeout' | 'unavailable' | 'database' | 'unknown' + +// ============================================================================ +// Filter Operator Mapping +// ============================================================================ + +/** + * Maps QuerySpec filter operators to SPARQL fluent expressions. + * + * Uses the fluent API from @okikio/sparql for type-safe comparisons. + */ +const OPERATOR_MAP: Record< + FilterOperator, + (field: string, value: unknown) => SparqlValue +> = { + // Equality + eq: (field, value) => v(field).eq(value as string | number | boolean), + ne: (field, value) => v(field).neq(value as string | number | boolean), + + // Comparisons + gt: (field, value) => v(field).gt(value as string | number), + gte: (field, value) => v(field).gte(value as string | number), + lt: (field, value) => v(field).lt(value as string | number), + lte: (field, value) => v(field).lte(value as string | number), + + // String operations (case-sensitive) + contains: (field, value) => v(field).contains(value as string), + startswith: (field, value) => v(field).startsWith(value as string), + endswith: (field, value) => v(field).endsWith(value as string), + + // String operations (case-insensitive) + icontains: (field, value) => v(field).lcase().contains((value as string).toLowerCase()), + + // Null checks + is_null: (field) => v(field).isNull(), + is_not_null: (field) => v(field).isNotNull(), + + // Special operators (handled separately) + in: () => { throw new Error('IN operator handled separately') }, + nin: () => { throw new Error('NOT IN operator handled separately') }, + between: () => { throw new Error('BETWEEN operator handled separately') }, +} + +// ============================================================================ +// Query Building Functions +// ============================================================================ + +/** + * Convert QuerySpec filters to SPARQL filter expressions. + * + * Handles: + * - Standard operators via fluent API + * - Special operators (in, nin, between) with custom logic + * - Multiple filters combined with AND + */ +function buildFilters(filters: QuerySpec['filters']): SparqlValue[] { + if (!filters || filters.length === 0) return [] + + return filters.map((filter) => { + const { field, operator, value } = filter + + // Handle special operators + if (operator === 'in') { + // Build OR chain: ?field = val1 OR ?field = val2 ... + const values = value as unknown[] + const conditions = values.map(val => v(field).eq(val as string | number | boolean)) + // Use raw to combine with OR since fluent .or() takes single arg + return raw(`(${conditions.map(c => c.value).join(' || ')})`) + } + + if (operator === 'nin') { + // Build AND chain: ?field != val1 AND ?field != val2 ... + const values = value as unknown[] + const conditions = values.map(val => v(field).neq(val as string | number | boolean)) + return raw(`(${conditions.map(c => c.value).join(' && ')})`) + } + + if (operator === 'between') { + // Convert to: ?field >= min AND ?field <= max + const [min, max] = value as [number, number] + return raw(`(${v(field).gte(min).value} && ${v(field).lte(max).value})`) + } + + // Standard operators via map + const mapFn = OPERATOR_MAP[operator] + if (!mapFn) { + throw new Error(`Unsupported filter operator: ${operator}`) + } + + return mapFn(field, value) + }) +} + +/** + * Convert QuerySpec sorts to SPARQL ORDER BY specifications. + */ +function buildSorts(sorts: QuerySpec['sorts']): Array<{ variable: string; direction?: 'ASC' | 'DESC' }> { + if (!sorts || sorts.length === 0) return [] + + return sorts.map(({ field, direction }) => ({ + variable: field, + direction: direction === 'asc' ? 'ASC' : 'DESC', + })) +} + +/** + * Build cursor pagination filter. + * + * Generates comparison based on sort direction: + * - DESC: sortField < sortValue OR (sortField = sortValue AND tiebreaker < tiebreakerValue) + * - ASC: sortField > sortValue OR (sortField = sortValue AND tiebreaker > tiebreakerValue) + */ +function buildCursorFilter( + sortField: string, + sortValue: string | number | Date, + tiebreaker: string, + tiebreakerValue: string | number, + direction: SortDirection, +): SparqlValue { + // Handle date encoding for temporal fields + const encodedValue = sortValue instanceof Date + ? `"${sortValue.toISOString().split('T')[0]}"^^xsd:date` + : sortValue + + if (direction === 'desc') { + // For DESC: next page has smaller values + return raw( + `(${v(sortField).lt(encodedValue).value} || ` + + `(${v(sortField).eq(encodedValue).value} && ` + + `STR(${v(tiebreaker).value}) < ${JSON.stringify(String(tiebreakerValue))}))` + ) + } else { + // For ASC: next page has larger values + return raw( + `(${v(sortField).gt(encodedValue).value} || ` + + `(${v(sortField).eq(encodedValue).value} && ` + + `STR(${v(tiebreaker).value}) > ${JSON.stringify(String(tiebreakerValue))}))` + ) + } +} + +/** + * Apply QuerySpec to a SPARQL query builder. + * + * Applies in order: + * 1. Base patterns (security/scoping) + * 2. Filters (WHERE + FILTER) + * 3. Cursor filter (if cursor pagination) + * 4. Sorts (ORDER BY) + * 5. Pagination (LIMIT/OFFSET) + */ +function applyQuerySpec( + builder: ReturnType, + querySpec: QuerySpec, + options: SparqlQueryOptions, +): ReturnType { + let query = builder + + // 1. Apply base patterns (security/scoping) + if (options.basePatterns && options.basePatterns.length > 0) { + for (const pattern of options.basePatterns) { + query = query.where( + triple(pattern.subject, pattern.predicate, pattern.object) + ) + } + } + + // 2. Apply filters + const filterExprs = buildFilters(querySpec.filters) + for (const filterExpr of filterExprs) { + query = query.filter(filterExpr) + } + + // 3. Apply cursor filter if present + if (querySpec.pagination.type === 'cursor' && querySpec.pagination.decodedCursor) { + const { sortField, sortValue, tiebreaker, tiebreakerValue, direction } = + querySpec.pagination.decodedCursor + + const cursorFilter = buildCursorFilter( + sortField, + sortValue, + tiebreaker, + tiebreakerValue, + direction + ) + query = query.filter(cursorFilter) + } + + // 4. Apply sorts + const sorts = buildSorts(querySpec.sorts) + for (const sort of sorts) { + query = query.orderBy(sort.variable, sort.direction) + } + + // 5. Apply pagination + if (querySpec.pagination.type === 'offset') { + const { limit, offset } = querySpec.pagination + query = query.limit(limit).offset(offset) + } else { + // Cursor pagination uses limit only + query = query.limit(querySpec.pagination.limit) + } + + return query +} + +// ============================================================================ +// Main Execution Function +// ============================================================================ + +/** + * Execute a SPARQL query with error handling and result transformation. + * + * Returns the raw SPARQL JSON result, throwing on error. Use transformSparqlResults + * to convert bindings to plain objects. + */ +async function executeSparqlQuery( + query: ReturnType, + querySpec: QuerySpec, + options: SparqlQueryOptions, +): Promise> { + try { + // Build final query with prefixes + const builtQuery = applyQuerySpec(query, querySpec, options).build() + + // Execute query + const result = await executeSparql>( + { + endpoint: options.endpoint, + timeoutMs: options.timeout ?? 30000, + }, + builtQuery, + { timeoutMs: options.timeout ?? 30000 } + ) + + return result + } catch (error) { + // executeSparql throws QueryError, not returns error object + if (error instanceof QueryError) { + // Re-throw with error kind for downstream handling + throw error + } + + // Unknown error type + throw new QueryError({ + kind: 'unknown', + message: error instanceof Error ? error.message : 'Unknown error during SPARQL execution', + query: query.build().value, + cause: error, + }) + } +} + +/** + * Transform SPARQL JSON bindings to plain objects. + * + * Uses the library's transformResults to handle type coercion: + * - xsd:integer → number + * - xsd:decimal/double → number + * - xsd:boolean → boolean + * - Literals → string + */ +function transformSparqlResults( + result: QueryResult +): Array<{ [K in keyof T]: unknown }> { + return transformResults(result) +} + +// ============================================================================ +// Convenience Wrappers +// ============================================================================ + +/** + * Execute a SPARQL query without base patterns. + * + * For queries that don't need security/scoping constraints. + */ +export async function querySparql( + query: ReturnType, + querySpec: QuerySpec, + options: Omit, +): Promise> { + const fullOptions: SparqlQueryOptions = { + ...options, + basePatterns: undefined, + } + + const result = await executeSparqlQuery(query, querySpec, fullOptions) + return transformSparqlResults(result) +} + +/** + * Execute a SPARQL query with base security patterns. + * + * Base patterns are applied before QuerySpec filters for tenant isolation, + * soft deletes, etc. + */ +export async function querySparqlWithBase( + query: ReturnType, + querySpec: QuerySpec, + options: SparqlQueryOptions, +): Promise> { + const result = await executeSparqlQuery(query, querySpec, options) + return transformSparqlResults(result) +} + + +export function queryPreview(query: string, maxLen: number = 200): string { + const cleaned = query.replace(/\s+/g, ' ').trim() + return cleaned.length > maxLen ? `${cleaned.slice(0, maxLen)}…` : cleaned +} + +/** + * Map QueryError.kind to RFC7807 utility factories. + * + * Policy decisions: + * - Upstream timeouts => 504 (gatewayTimeout) + * - Upstream connectivity issues => 503 (serviceUnavailable) + * - Upstream protocol mismatch => 502 (badGateway) + * - Upstream 4xx: + * - 400/422 => treat as client query issue (badRequest / unprocessableEntity if you prefer) + * - 429 => rateLimitExceeded (if you have retryAfter; else pick a default) + * - 401/403 and most other 4xx => treat as upstream misconfig (badGateway), not client auth + * - Upstream 5xx => badGateway/serviceUnavailable depending on status + */ +export function mapSparqlQueryErrorToProblem( + instance: string, + query: string, + error: QueryError, +): ErrorResult { + switch (error.kind) { + case 'timeout': { + return gatewayTimeout( + instance, + `SPARQL upstream timed out: ${error.message}`, + Object.assign( + { query_preview: queryPreview(query) }, + typeof error?.status === 'number' + ? { upstream_status: error?.status } + : {}, + ), + ) + } + + case 'abort': { + // Closest standard utility you already have is 408. + // If you later add a dedicated 499 handler, swap it. + return requestTimeout( + instance, + `SPARQL request was aborted: ${error.message}`, + { query_preview: queryPreview(query) }, + ) + } + + case 'network': { + return serviceUnavailable( + instance, + 'graph-database', + `Unable to reach SPARQL upstream: ${error.message}`, + Object.assign( + { query_preview: queryPreview(query) }, + { kind: 'network' }, + ), + ) + } + + case 'protocol': { + return badGateway( + instance, + `SPARQL upstream returned an unexpected response format: ${error.message}`, + { query_preview: queryPreview(query) }, + ) + } + + case 'http': { + const upstreamStatus = + typeof error?.status === 'number' + ? error?.status + : 502 + + // Prefer structured hints, but do not leak giant bodies. + const responseBody = + (error as { responseBody?: unknown }).responseBody + + // Upstream says: "your query is invalid" + if (upstreamStatus === 400 || upstreamStatus === 422) { + return badRequest( + instance, + `SPARQL query rejected by upstream: ${error.message}`, + Object.assign( + { query_preview: queryPreview(query) }, + responseBody !== undefined ? { upstream_body: responseBody } : {}, + { upstream_status: upstreamStatus }, + ), + ) + } + + // Upstream rate limiting + if (upstreamStatus === 429) { + // If you later plumb Retry-After from upstream headers, use it here. + const retryAfter = 60 + return rateLimitExceeded( + instance, + retryAfter, + Object.assign( + { query_preview: queryPreview(query) }, + { upstream_status: upstreamStatus }, + ), + ) + } + + // Upstream is unhealthy + if (upstreamStatus === 503) { + return serviceUnavailable( + instance, + 'graph-database', + `SPARQL upstream service unavailable: ${error.message}`, + Object.assign( + { query_preview: queryPreview(query) }, + { upstream_status: upstreamStatus }, + responseBody !== undefined ? { upstream_body: responseBody } : {}, + ), + ) + } + + if (upstreamStatus === 504) { + return gatewayTimeout( + instance, + `SPARQL upstream gateway timed out: ${error.message}`, + Object.assign( + { query_preview: queryPreview(query) }, + { upstream_status: upstreamStatus }, + ), + ) + } + + // Do NOT leak upstream auth/permission failures as if they were *your* API auth. + // These are typically config issues (bad credentials between services). + if (upstreamStatus === 401 || upstreamStatus === 403) { + return badGateway( + instance, + `SPARQL upstream authorization failure: ${error.message}`, + Object.assign( + { query_preview: queryPreview(query) }, + { upstream_status: upstreamStatus }, + responseBody !== undefined ? { upstream_body: responseBody } : {}, + ), + ) + } + + // All other upstream HTTP errors => Bad Gateway by default + return badGateway( + instance, + `SPARQL upstream HTTP ${upstreamStatus}: ${error.message}`, + Object.assign( + { query_preview: queryPreview(query) }, + { upstream_status: upstreamStatus }, + responseBody !== undefined ? { upstream_body: responseBody } : {}, + ), + ) + } + + case 'unknown': + default: { + // If this can only happen inside executeSparql, it's usually upstream-adjacent. + // If you want strictly-server errors here, switch to internalServerError(). + return badGateway( + instance, + `Unexpected SPARQL error: ${error.message}`, + { query_preview: queryPreview(query) }, + ) + } + } +} + + +// Export for use in handlers +export { executeSparqlQuery, transformSparqlResults, applyQuerySpec } \ No newline at end of file diff --git a/supabase/functions/_shared/query/execution/supabase.ts b/supabase/functions/_shared/execution/supabase.ts similarity index 84% rename from supabase/functions/_shared/query/execution/supabase.ts rename to supabase/functions/_shared/execution/supabase.ts index 43260ef..09e185d 100644 --- a/supabase/functions/_shared/query/execution/supabase.ts +++ b/supabase/functions/_shared/execution/supabase.ts @@ -9,22 +9,14 @@ * - Base filter support for security/scoping */ -import type { BaseFiltersNormalized, CursorData, FieldSelectionNormalized, FiltersNormalized, PaginationNormalized, SortsNormalized } from '@platform/backend/query/schemas.ts' - -import type { Database } from '@platform/shared/types/index.ts' +import type { BaseFiltersNormalized, CursorData, FieldSelectionNormalized, FiltersNormalized, PaginationNormalized, SortsNormalized } from '../query/schemas.ts' import type { SupabaseClient } from '@supabase/supabase-js' -import type { QuerySpec } from '@platform/backend/query/schemas.ts' -import { ok } from '@platform/backend/response/success.ts' -import { badRequest, exception, internalServerError } from '@platform/backend/response/errors.ts' +import type { Database } from '../types/database.ts' +import type { QuerySpec } from '../query/schemas.ts' - -// ============================================================================ -// SUPABASE CLIENT TYPES -// ============================================================================ - -export type SupabaseClientType = SupabaseClient -export type SupabaseClientSelectType = ReturnType['select']> +import { ok } from '../response/success.ts' +import { badRequest, exception, internalServerError } from '../response/errors.ts' // ============================================================================ // EXECUTION OPTIONS @@ -62,6 +54,81 @@ export interface CollectionQueryOptions { baseFilters?: BaseFiltersNormalized } +// ============================================================================ +// QUERY EXECUTION +// ============================================================================ + +/** + * Database schemas excluding Supabase internals. + */ +type PublicDatabase = Omit; + +/** + * Schema names that exist in your generated Supabase types. + * + * Note: + * - `Extract<..., string>` ensures the schema name is a string literal type, + * not `string | number | symbol`. + */ +export type SchemaName = Extract; + +/** + * Narrow a schema to what `postgrest-js` expects (`GenericSchema`). + * This matches how `supabase-js` types `.from(...)`. + */ +type SchemaDef = + PublicDatabase[TSchema] extends { Tables: infer R } ? PublicDatabase[TSchema] : never; + + +/** + * Table names for a schema (string keys only). + */ +export type TableName = Extract< + keyof SchemaDef["Tables"], + string +>; + +/** + * View names for a schema (string keys only). + */ +export type ViewName = Extract< + keyof SchemaDef["Views"], + string +>; + +/** + * A "relation" in PostgREST terms: table OR view. + * This matches the overload set of `supabase.from(...)`. + */ +export type RelationName = + | TableName + | ViewName; + +/** + * Row type for a relation (table/view). + */ +export type RelationRow< + TSchema extends SchemaName, + TRelation extends RelationName, +> = + TRelation extends TableName + ? SchemaDef["Tables"][TRelation] extends { Row: infer R } ? R : never + : TRelation extends ViewName + ? SchemaDef["Views"][TRelation] extends { Row: infer R } ? R : never + : never; + +/** + * Typed PostgREST client for a schema. + */ +export type SupabaseClientType = SupabaseClient +export type RestClient = + SupabaseClientType["rest"]; + +export type SupabaseClientSelectType = + ReturnType< + ReturnType['from']>['select'] + > + // ============================================================================ // SUPABASE APPLICATION (existing, mostly unchanged) // ============================================================================ @@ -135,8 +202,10 @@ export function buildSelectClause( * { field: 'id', direction: 'asc' } * ]) */ -export function applySorts( - query: SupabaseClientSelectType, +export function applySorts< + TSchema extends SchemaName, +>( + query: SupabaseClientSelectType, sorts: SortsNormalized | null ) { // No-op when sorting disabled @@ -144,7 +213,7 @@ export function applySorts( return query } - let result = query + let result = query; for (const sort of sorts) { result = result.order(sort.field, { @@ -169,8 +238,10 @@ export function applySorts( * { field: 'price', operator: 'gte', value: 50 } * ]) */ -export function applyFilters( - query: SupabaseClientSelectType, +export function applyFilters< + TSchema extends SchemaName, +>( + query: SupabaseClientSelectType, filters: FiltersNormalized | null ) { // No-op when filtering disabled @@ -181,9 +252,11 @@ export function applyFilters( for (const filter of filters) { switch (filter.operator) { case 'eq': + // @ts-ignore ignore errors result = result.eq(filter.field, filter.value as string) break case 'ne': + // @ts-ignore ignore errors result = result.neq(filter.field, filter.value) break case 'gt': @@ -422,8 +495,10 @@ export function applyFilters( * const hasMore = rows.length > 20 * ``` */ -export function applyCursorPagination( - query: SupabaseClientSelectType, +export function applyCursorPagination< + TSchema extends SchemaName, +>( + query: SupabaseClientSelectType, cursor: CursorData, limit: number ) { @@ -502,8 +577,10 @@ export function applyCursorPagination( /** * Apply offset pagination to Supabase query */ -export function applyOffsetPagination( - query: SupabaseClientSelectType, +export function applyOffsetPagination< + TSchema extends SchemaName, +>( + query: SupabaseClientSelectType, offset: number, limit: number ) { @@ -513,8 +590,10 @@ export function applyOffsetPagination( /** * Apply pagination (auto-detects type) */ -export function applyPagination( - query: SupabaseClientSelectType, +export function applyPagination< + TSchema extends SchemaName, +>( + query: SupabaseClientSelectType, params: PaginationNormalized ) { if (params.type === 'cursor') { @@ -560,10 +639,12 @@ export function applyPagination( * query = applyQuerySpec(query, spec) * // Only pagination is applied */ -export function applyQuerySpec( - query: SupabaseClientSelectType, +export function applyQuerySpec< + TSchema extends SchemaName, +>( + query: SupabaseClientSelectType, spec: QuerySpec -): SupabaseClientSelectType { +) { // Apply filters only if enabled if (spec.filters !== null) { query = applyFilters(query, spec.filters) @@ -580,10 +661,6 @@ export function applyQuerySpec( return query } -// ============================================================================ -// QUERY EXECUTION -// ============================================================================ - /** * Execute a paginated collection query with proper error handling * @@ -636,13 +713,13 @@ export function applyQuerySpec( * ) */ export async function executeCollectionQuery< - TTable extends keyof Database['public']['Tables'], - TData = Database['public']['Tables'][TTable]['Row'] + TSchema extends SchemaName, + TRelation extends RelationName, >( - supabase: SupabaseClientType, - table: TTable, + supabase: RestClient, + relation: TRelation, spec: QuerySpec, - options?: CollectionQueryOptions + options?: CollectionQueryOptions, ) { const { includeCount = false, @@ -654,10 +731,10 @@ export async function executeCollectionQuery< const selectClause = buildSelectClause(spec.fields) // Create base query with optional count - let query = supabase.from(table).select(selectClause, { + let query = supabase.from(relation).select(selectClause, { count: includeCount ? countStrategy : undefined, head: false, - }) as SupabaseClientSelectType + }) as SupabaseClientSelectType // Apply base filters first (security/scoping) if (baseFilters && baseFilters.length > 0) { @@ -682,7 +759,7 @@ export async function executeCollectionQuery< // Success case return ok( - (result.data as TData[]) ?? [], + (result.data as RelationRow[]) ?? [], 200, { total: result.count ?? null } ) @@ -698,13 +775,16 @@ export async function executeCollectionQuery< * @example * const result = await queryCollection(supabase, 'posts', querySpec) */ -export function queryCollection( - supabase: SupabaseClientType, - table: TTable, +export function queryCollection< + TSchema extends SchemaName, + TRelation extends RelationName, +>( + supabase: RestClient, + relation: TRelation, spec: QuerySpec, baseFilters?: CollectionQueryOptions['baseFilters'] ) { - return executeCollectionQuery(supabase, table, spec, { + return executeCollectionQuery(supabase, relation, spec, { includeCount: false, baseFilters }) @@ -721,9 +801,12 @@ export function queryCollection(opts: { - supabase: SupabaseClientType, - table: TTable, +export function queryCollectionWithCount< + TSchema extends SchemaName, + TRelation extends RelationName, +>(opts: { + supabase: RestClient, + table: TRelation, spec: QuerySpec, countStrategy: 'exact' | 'planned' | 'estimated', baseFilters?: CollectionQueryOptions['baseFilters'] diff --git a/supabase/functions/_shared/execution/supabase_test.ts b/supabase/functions/_shared/execution/supabase_test.ts new file mode 100644 index 0000000..5b748b3 --- /dev/null +++ b/supabase/functions/_shared/execution/supabase_test.ts @@ -0,0 +1,1044 @@ +// utils/query/execution/supabase_test.ts +/** + * Unit tests for Supabase query execution utilities + * + * Test Structure (Effect-TS inspired - precise behavioral contracts): + * + * 1. APPLY FILTERS + * - All operator translations to Supabase methods + * - Null/empty handling + * + * 2. APPLY SORTS + * - Order method chaining + * - Direction handling (asc/desc) + * - Multiple sorts + * + * 3. APPLY PAGINATION + * - Cursor mode predicate building + * - Offset mode range setting + * - Limit + 1 for hasMore detection + * + * 4. EXECUTE COLLECTION QUERY + * - Integration of all apply functions + * - Base filters handling + * - Count strategies + */ + +import { describe, it } from '@std/testing/bdd' +import { expect } from '@std/expect' + +import { + applyFilters, + applySorts, + applyPagination, + buildSelectClause, + applyCursorPagination, + executeCollectionQuery, +} from './supabase.ts' + +import type { FiltersNormalized, SortsNormalized, PaginationNormalized, BaseFiltersNormalized, QuerySpec } from '../query/schemas.ts' + +// ============================================================================ +// MOCK SUPABASE QUERY BUILDER +// ============================================================================ + +/** + * Creates a mock Supabase query builder that records method calls + * for verification. Each method returns `this` for chaining. + */ +function createMockQueryBuilder() { + const calls: { method: string; args: unknown[] }[] = [] + + const builder = { + _calls: calls, + + eq(column: string, value: unknown) { + calls.push({ method: 'eq', args: [column, value] }) + return this + }, + + neq(column: string, value: unknown) { + calls.push({ method: 'neq', args: [column, value] }) + return this + }, + + gt(column: string, value: unknown) { + calls.push({ method: 'gt', args: [column, value] }) + return this + }, + + gte(column: string, value: unknown) { + calls.push({ method: 'gte', args: [column, value] }) + return this + }, + + lt(column: string, value: unknown) { + calls.push({ method: 'lt', args: [column, value] }) + return this + }, + + lte(column: string, value: unknown) { + calls.push({ method: 'lte', args: [column, value] }) + return this + }, + + in(column: string, values: unknown[]) { + calls.push({ method: 'in', args: [column, values] }) + return this + }, + + not(column: string, operator: string, value: unknown) { + calls.push({ method: 'not', args: [column, operator, value] }) + return this + }, + + like(column: string, pattern: string) { + calls.push({ method: 'like', args: [column, pattern] }) + return this + }, + + ilike(column: string, pattern: string) { + calls.push({ method: 'ilike', args: [column, pattern] }) + return this + }, + + is(column: string, value: null) { + calls.push({ method: 'is', args: [column, value] }) + return this + }, + + order(column: string, options: { ascending: boolean }) { + calls.push({ method: 'order', args: [column, options] }) + return this + }, + + or(predicate: string) { + calls.push({ method: 'or', args: [predicate] }) + return this + }, + + range(from: number, to: number) { + calls.push({ method: 'range', args: [from, to] }) + return this + }, + + limit(count: number) { + calls.push({ method: 'limit', args: [count] }) + return this + }, + + // Helper to get calls for a specific method + getCallsFor(method: string) { + return calls.filter(c => c.method === method) + }, + + // Helper to check if a method was called with specific args + wasCalledWith(method: string, ...args: unknown[]) { + return calls.some(c => + c.method === method && + JSON.stringify(c.args) === JSON.stringify(args) + ) + } + } + + return builder +} + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +function makeFilter(overrides: Partial = {}): FiltersNormalized[0] { + return { + field: 'status', + operator: 'eq', + value: 'active', + ...overrides + } +} + +function makeSort(overrides: Partial = {}): SortsNormalized[0] { + return { + field: 'created_at', + direction: 'desc', + tiebreaker: false, + ...overrides + } +} + +function createMockSupabase( + mockResult: { data: unknown[]; count?: number | null; error?: { message: string } | null } +) { + const calls: Record = {} + + const supabase: any = { + _calls: calls, + _lastQuery: undefined, + from(table: string) { + calls.from = [table] + return { + select(columns: string, options?: { count?: string; head?: boolean }) { + calls.select = [columns, options] + const builder = createMockQueryBuilder() + const thenable: any = builder + thenable.then = (resolve: (result: typeof mockResult) => void) => resolve(mockResult) + supabase._lastQuery = thenable + return thenable + } + } + } + } + + return supabase +} + +describe('buildSelectClause', () => { + it('returns * for null selection', () => { + const result = buildSelectClause(null) + expect(result).toBe('*') + }) + + it('joins simple fields with comma', () => { + const result = buildSelectClause({ + type: 'simple', + fields: ['id', 'name', 'price'] + }) + + expect(result).toBe('id,name,price') + }) + + it('extracts fields for specific resourceType in JSON:API selection', () => { + const result = buildSelectClause( + { + type: 'jsonapi', + fields: { + products: ['id', 'name'], + categories: ['name'] + } + }, + 'products' + ) + + expect(result).toBe('id,name') + }) + + it('uses first resource fields when resourceType is not provided', () => { + const result = buildSelectClause({ + type: 'jsonapi', + fields: { + products: ['id', 'name'], + categories: ['name'] + } + }) + + expect(result === 'id,name' || result === 'name').toBe(true) + }) + + it('returns * when resourceType is not found in JSON:API selection', () => { + const result = buildSelectClause( + { + type: 'jsonapi', + fields: { + products: ['id', 'name'] + } + }, + 'orders' + ) + + expect(result).toBe('*') + }) +} +) + +// ============================================================================ +// 1. APPLY FILTERS +// ============================================================================ + +describe('applyFilters', () => { + describe('equality operators', () => { + it('eq -> query.eq(field, value)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [makeFilter({ operator: 'eq', value: 'published' })]) + + expect(query.wasCalledWith('eq', 'status', 'published')).toBe(true) + }) + + it('ne -> query.neq(field, value)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [makeFilter({ operator: 'ne', value: 'draft' })]) + + expect(query.wasCalledWith('neq', 'status', 'draft')).toBe(true) + }) + }) + + describe('comparison operators', () => { + it('gt -> query.gt(field, value)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [makeFilter({ field: 'price', operator: 'gt', value: 100 })]) + + expect(query.wasCalledWith('gt', 'price', 100)).toBe(true) + }) + + it('gte -> query.gte(field, value)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [makeFilter({ field: 'price', operator: 'gte', value: 50 })]) + + expect(query.wasCalledWith('gte', 'price', 50)).toBe(true) + }) + + it('lt -> query.lt(field, value)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [makeFilter({ field: 'price', operator: 'lt', value: 200 })]) + + expect(query.wasCalledWith('lt', 'price', 200)).toBe(true) + }) + + it('lte -> query.lte(field, value)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [makeFilter({ field: 'price', operator: 'lte', value: 150 })]) + + expect(query.wasCalledWith('lte', 'price', 150)).toBe(true) + }) + }) + + describe('between operator', () => { + it('between -> query.gte(field, min).lte(field, max)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ field: 'price', operator: 'between', value: [50, 200] }) + ]) + + expect(query.wasCalledWith('gte', 'price', 50)).toBe(true) + expect(query.wasCalledWith('lte', 'price', 200)).toBe(true) + }) + }) + + describe('set operators', () => { + it('in -> query.in(field, values)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ operator: 'in', value: ['draft', 'published'] }) + ]) + + expect(query.wasCalledWith('in', 'status', ['draft', 'published'])).toBe(true) + }) + + it('in with string value -> splits and calls query.in', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ operator: 'in', value: 'draft,published' }) + ]) + + expect(query.wasCalledWith('in', 'status', ['draft', 'published'])).toBe(true) + }) + + it('nin -> query.not(field, "in", values)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ operator: 'nin', value: ['archived', 'deleted'] }) + ]) + + expect(query.wasCalledWith('not', 'status', 'in', ['archived', 'deleted'])).toBe(true) + }) + }) + + describe('string pattern operators', () => { + it('contains -> query.like(field, "%value%")', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ field: 'title', operator: 'contains', value: 'search' }) + ]) + + expect(query.wasCalledWith('like', 'title', '%search%')).toBe(true) + }) + + it('icontains -> query.ilike(field, "%value%")', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ field: 'title', operator: 'icontains', value: 'SEARCH' }) + ]) + + expect(query.wasCalledWith('ilike', 'title', '%SEARCH%')).toBe(true) + }) + + it('startswith -> query.like(field, "value%")', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ field: 'title', operator: 'startswith', value: 'Hello' }) + ]) + + expect(query.wasCalledWith('like', 'title', 'Hello%')).toBe(true) + }) + + it('endswith -> query.like(field, "%value")', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ field: 'title', operator: 'endswith', value: 'World' }) + ]) + + expect(query.wasCalledWith('like', 'title', '%World')).toBe(true) + }) + }) + + describe('null operators', () => { + it('is_null -> query.is(field, null)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ field: 'deleted_at', operator: 'is_null' }) + ]) + + expect(query.wasCalledWith('is', 'deleted_at', null)).toBe(true) + }) + + it('is_not_null -> query.not(field, "is", null)', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ field: 'published_at', operator: 'is_not_null' }) + ]) + + expect(query.wasCalledWith('not', 'published_at', 'is', null)).toBe(true) + }) + }) + + describe('multiple filters', () => { + it('applies all filters in order', () => { + const query = createMockQueryBuilder() + + applyFilters(query as any, [ + makeFilter({ field: 'status', operator: 'eq', value: 'published' }), + makeFilter({ field: 'price', operator: 'gte', value: 50 }), + makeFilter({ field: 'price', operator: 'lte', value: 200 }) + ]) + + const calls = query._calls + expect(calls).toHaveLength(3) + expect(calls[0].method).toBe('eq') + expect(calls[1].method).toBe('gte') + expect(calls[2].method).toBe('lte') + }) + }) + + describe('null/empty handling', () => { + it('returns query unchanged when filters is null', () => { + const query = createMockQueryBuilder() + + const result = applyFilters(query as any, null) + + expect(query._calls).toHaveLength(0) + expect(result).toBe(query) + }) + + it('returns query unchanged when filters is empty array', () => { + const query = createMockQueryBuilder() + + const result = applyFilters(query as any, []) + + expect(query._calls).toHaveLength(0) + expect(result).toBe(query) + }) + }) +}) + +// ============================================================================ +// 2. APPLY SORTS +// ============================================================================ + +describe('applySorts', () => { + describe('single sort', () => { + it('applies ascending sort', () => { + const query = createMockQueryBuilder() + + applySorts(query as any, [makeSort({ direction: 'asc' })]) + + expect(query.wasCalledWith('order', 'created_at', { ascending: true })).toBe(true) + }) + + it('applies descending sort', () => { + const query = createMockQueryBuilder() + + applySorts(query as any, [makeSort({ direction: 'desc' })]) + + expect(query.wasCalledWith('order', 'created_at', { ascending: false })).toBe(true) + }) + }) + + describe('multiple sorts', () => { + it('chains multiple sorts in order', () => { + const query = createMockQueryBuilder() + + applySorts(query as any, [ + makeSort({ field: 'created_at', direction: 'desc' }), + makeSort({ field: 'id', direction: 'asc', tiebreaker: true }) + ]) + + const orderCalls = query.getCallsFor('order') + expect(orderCalls).toHaveLength(2) + expect(orderCalls[0].args[0]).toBe('created_at') + expect(orderCalls[1].args[0]).toBe('id') + }) + }) + + describe('null/empty handling', () => { + it('returns query unchanged when sorts is null', () => { + const query = createMockQueryBuilder() + + const result = applySorts(query as any, null) + + expect(query._calls).toHaveLength(0) + expect(result).toBe(query) + }) + + it('returns query unchanged when sorts is empty array', () => { + const query = createMockQueryBuilder() + + const result = applySorts(query as any, []) + + expect(query._calls).toHaveLength(0) + expect(result).toBe(query) + }) + }) +}) + +// ============================================================================ +// 3. APPLY PAGINATION +// ============================================================================ + +describe('applyPagination', () => { + describe('offset mode', () => { + it('applies range for offset pagination', () => { + const query = createMockQueryBuilder() + const pagination: PaginationNormalized = { + type: 'offset', + offset: 20, + limit: 10 + } + + applyPagination(query as any, pagination) + + expect(query.wasCalledWith('range', 20, 29)).toBe(true) + }) + + it('handles first page (offset 0)', () => { + const query = createMockQueryBuilder() + const pagination: PaginationNormalized = { + type: 'offset', + offset: 0, + limit: 20 + } + + applyPagination(query as any, pagination) + + expect(query.wasCalledWith('range', 0, 19)).toBe(true) + }) + }) + + describe('cursor mode', () => { + it('applies or predicate for cursor pagination (desc)', () => { + const query = createMockQueryBuilder() + const pagination: PaginationNormalized = { + type: 'cursor', + limit: 20, + decodedCursor: { + sortField: 'created_at', + sortValue: '2024-01-15T10:00:00Z', + tiebreaker: 'id', + tiebreakerValue: 'item-123', + direction: 'desc', + createdAt: new Date() + } + } + + applyPagination(query as any, pagination) + + // Should have .or() call with proper predicate + const orCalls = query.getCallsFor('or') + expect(orCalls.length).toBeGreaterThan(0) + + // For desc: (sortField < sortValue) OR (sortField = sortValue AND tiebreaker < tiebreakerValue) + const predicate = orCalls[0].args[0] as string + expect(predicate).toContain('created_at') + expect(predicate).toContain('id') + }) + + it('applies or predicate for cursor pagination (asc)', () => { + const query = createMockQueryBuilder() + const pagination: PaginationNormalized = { + type: 'cursor', + limit: 20, + decodedCursor: { + sortField: 'created_at', + sortValue: '2024-01-15T10:00:00Z', + tiebreaker: 'id', + tiebreakerValue: 'item-123', + direction: 'asc', + createdAt: new Date() + } + } + + applyPagination(query as any, pagination) + + // For asc: (sortField > sortValue) OR (sortField = sortValue AND tiebreaker > tiebreakerValue) + const orCalls = query.getCallsFor('or') + expect(orCalls.length).toBeGreaterThan(0) + }) + + it('requests limit + 1 for hasMore detection', () => { + const query = createMockQueryBuilder() + const pagination: PaginationNormalized = { + type: 'cursor', + limit: 20, + decodedCursor: { + sortField: 'created_at', + sortValue: '2024-01-15T10:00:00Z', + tiebreaker: 'id', + tiebreakerValue: 'item-123', + direction: 'desc', + createdAt: new Date() + } + } + + applyPagination(query as any, pagination) + + // Should request limit + 1 = 21 + expect(query.wasCalledWith('limit', 21)).toBe(true) + }) + + it('handles first page (no cursor)', () => { + const query = createMockQueryBuilder() + const pagination: PaginationNormalized = { + type: 'cursor', + limit: 20 + // No cursor = first page + } + + applyPagination(query as any, pagination) + + // Should just set limit, no .or() predicate + const orCalls = query.getCallsFor('or') + expect(orCalls).toHaveLength(0) + expect(query.wasCalledWith('limit', 21)).toBe(true) + }) + }) +}) + +describe('applyCursorPagination', () => { + it('builds correct predicate for descending cursor pagination', () => { + const query = createMockQueryBuilder() + const cursor = { + sortField: 'created_at', + sortValue: '2024-01-15T10:00:00Z', + tiebreaker: 'id', + tiebreakerValue: 'uuid-123', + direction: 'desc' as const, + createdAt: new Date() + } + + applyCursorPagination(query as any, cursor, 20) + + const orCalls = query.getCallsFor('or') + expect(orCalls).toHaveLength(1) + + const predicate = orCalls[0].args[0] as string + expect(predicate).toBe( + 'created_at.lt.2024-01-15T10:00:00Z,and(created_at.eq.2024-01-15T10:00:00Z,id.lt.uuid-123)' + ) + + expect(query.wasCalledWith('limit', 21)).toBe(true) + }) + + it('builds correct predicate for ascending cursor pagination', () => { + const query = createMockQueryBuilder() + const cursor = { + sortField: 'created_at', + sortValue: '2024-01-15T10:00:00Z', + tiebreaker: 'id', + tiebreakerValue: 'uuid-123', + direction: 'asc' as const, + createdAt: new Date() + } + + applyCursorPagination(query as any, cursor, 20) + + const orCalls = query.getCallsFor('or') + expect(orCalls).toHaveLength(1) + + const predicate = orCalls[0].args[0] as string + expect(predicate).toBe( + 'created_at.gt.2024-01-15T10:00:00Z,and(created_at.eq.2024-01-15T10:00:00Z,id.gt.uuid-123)' + ) + + expect(query.wasCalledWith('limit', 21)).toBe(true) + }) + + it('handles numeric cursor values', () => { + const query = createMockQueryBuilder() + const cursor = { + sortField: 'score', + sortValue: 98.5, + tiebreaker: 'id', + tiebreakerValue: 42, + direction: 'desc' as const, + createdAt: new Date() + } + + applyCursorPagination(query as any, cursor, 10) + + const orCalls = query.getCallsFor('or') + expect(orCalls).toHaveLength(1) + + const predicate = orCalls[0].args[0] as string + expect(predicate).toContain('score.lt.98.5') + expect(predicate).toContain('id.lt.42') + }) + + it('handles values with commas', () => { + const query = createMockQueryBuilder() + const cursor = { + sortField: 'title', + sortValue: 'Hello, World', + tiebreaker: 'id', + tiebreakerValue: 'abc', + direction: 'desc' as const, + createdAt: new Date() + } + + applyCursorPagination(query as any, cursor, 10) + + const orCalls = query.getCallsFor('or') + expect(orCalls.length).toBeGreaterThan(0) + }) + + it('handles values with parentheses', () => { + const query = createMockQueryBuilder() + const cursor = { + sortField: 'title', + sortValue: 'Hello (World)', + tiebreaker: 'id', + tiebreakerValue: 'abc', + direction: 'asc' as const, + createdAt: new Date() + } + + applyCursorPagination(query as any, cursor, 10) + + const orCalls = query.getCallsFor('or') + expect(orCalls.length).toBeGreaterThan(0) + }) +}) + +// ============================================================================ +// 4. BASE FILTERS INTEGRATION (IMPORTANT) +// ============================================================================ + +describe('base filters pattern', () => { + it('applies base filters before user filters', () => { + const query = createMockQueryBuilder() + + const baseFilters: FiltersNormalized = [ + { field: 'realm_id', operator: 'eq', value: 'realm-123' }, + { field: 'deleted_at', operator: 'is_null' } + ] + + const userFilters: FiltersNormalized = [ + { field: 'status', operator: 'eq', value: 'published' } + ] + + // Apply base filters first (as handlers do) + applyFilters(query as any, baseFilters) + applyFilters(query as any, userFilters) + + const calls = query._calls + expect(calls).toHaveLength(3) + + // Base filters should be first + expect(calls[0].args).toEqual(['realm_id', 'realm-123']) + expect(calls[1].args).toEqual(['deleted_at', null]) + + // User filter should be last + expect(calls[2].args).toEqual(['status', 'published']) + }) + + it('base filters cannot be overridden by user filters (they AND together)', () => { + const query = createMockQueryBuilder() + + const baseFilters: FiltersNormalized = [ + { field: 'deleted_at', operator: 'is_null' } + ] + + const userFilters: FiltersNormalized = [ + // User tries to override soft-delete filter + { field: 'deleted_at', operator: 'is_not_null' } + ] + + applyFilters(query as any, baseFilters) + applyFilters(query as any, userFilters) + + // Both filters are applied - they AND together + // This means the query will return no results (correct security behavior) + const calls = query._calls + expect(calls).toHaveLength(2) + expect(calls[0].method).toBe('is') + expect(calls[1].method).toBe('not') + }) +}) + +// ============================================================================ +// 5. PRODUCTION ENDPOINT SCENARIOS +// ============================================================================ + +describe('production endpoint scenarios', () => { + describe('collections list pattern', () => { + it('applies complete query spec for list endpoint', () => { + const query = createMockQueryBuilder() + + // Simulate what a list handler does + const baseFilters: FiltersNormalized = [ + { field: 'realm_id', operator: 'eq', value: 'realm-abc' }, + { field: 'deleted_at', operator: 'is_null' } + ] + + const userFilters: FiltersNormalized = [ + { field: 'visibility_code', operator: 'eq', value: 'public' }, + { field: 'collection_type', operator: 'in', value: ['reading_list', 'favorites'] } + ] + + const sorts: SortsNormalized = [ + { field: 'created_at', direction: 'desc', tiebreaker: false }, + { field: 'id', direction: 'desc', tiebreaker: true } + ] + + const pagination: PaginationNormalized = { + type: 'offset', + offset: 0, + limit: 50 + } + + // Apply in order handlers do + applyFilters(query as any, baseFilters) + applyFilters(query as any, userFilters) + applySorts(query as any, sorts) + applyPagination(query as any, pagination) + + // Verify complete call chain + expect(query._calls.length).toBeGreaterThan(5) + + // Filters + expect(query.wasCalledWith('eq', 'realm_id', 'realm-abc')).toBe(true) + expect(query.wasCalledWith('is', 'deleted_at', null)).toBe(true) + expect(query.wasCalledWith('eq', 'visibility_code', 'public')).toBe(true) + expect(query.wasCalledWith('in', 'collection_type', ['reading_list', 'favorites'])).toBe(true) + + // Sorts + expect(query.wasCalledWith('order', 'created_at', { ascending: false })).toBe(true) + expect(query.wasCalledWith('order', 'id', { ascending: false })).toBe(true) + + // Pagination + expect(query.wasCalledWith('range', 0, 49)).toBe(true) + }) + }) + + describe('search endpoint pattern', () => { + it('handles text search with contains operator', () => { + const query = createMockQueryBuilder() + + const filters: FiltersNormalized = [ + { field: 'title', operator: 'icontains', value: 'batman' }, + { field: 'status', operator: 'eq', value: 'published' } + ] + + applyFilters(query as any, filters) + + expect(query.wasCalledWith('ilike', 'title', '%batman%')).toBe(true) + expect(query.wasCalledWith('eq', 'status', 'published')).toBe(true) + }) + }) + + describe('date range pattern', () => { + it('handles date range filters for reporting', () => { + const query = createMockQueryBuilder() + + const filters: FiltersNormalized = [ + { field: 'created_at', operator: 'gte', value: '2024-01-01T00:00:00Z' }, + { field: 'created_at', operator: 'lt', value: '2024-02-01T00:00:00Z' } + ] + + applyFilters(query as any, filters) + + expect(query.wasCalledWith('gte', 'created_at', '2024-01-01T00:00:00Z')).toBe(true) + expect(query.wasCalledWith('lt', 'created_at', '2024-02-01T00:00:00Z')).toBe(true) + }) + }) +}) + +describe('executeCollectionQuery', () => { + it('executes basic query without count', async () => { + const mockResult = { + data: [{ id: '1' }, { id: '2' }], + count: undefined, + error: null + } + + const supabase = createMockSupabase(mockResult) + + const spec: QuerySpec = { + pagination: { + type: 'offset', + offset: 0, + limit: 20 + }, + filters: null, + sorts: null, + fields: null + } + + const result = await (executeCollectionQuery as any)( + supabase, + 'posts', + spec + ) + + const [body, status] = result as any + + expect(status).toBe(200) + expect(body.data).toEqual(mockResult.data) + expect(body.meta.total).toBeNull() + + const selectArgs = supabase._calls.select + expect(selectArgs[0]).toBe('*') + expect(selectArgs[1]).toEqual({ count: undefined, head: false }) + }) + + it('includes count with strategy when requested', async () => { + const mockResult = { + data: [{ id: '1' }], + count: 42, + error: null + } + + const supabase = createMockSupabase(mockResult) + + const spec: QuerySpec = { + pagination: { + type: 'offset', + offset: 0, + limit: 10 + }, + filters: null, + sorts: null, + fields: null + } + + const result = await (executeCollectionQuery as any)( + supabase, + 'posts', + spec, + { + includeCount: true, + countStrategy: 'estimated' + } + ) + + const [body, status] = result as any + + expect(status).toBe(200) + expect(body.meta.total).toBe(42) + + const selectArgs = supabase._calls.select + expect(selectArgs[1]).toEqual({ count: 'estimated', head: false }) + }) + + it('applies base filters before user filters', async () => { + const mockResult = { + data: [], + count: 0, + error: null + } + + const supabase = createMockSupabase(mockResult) + + const baseFilters: BaseFiltersNormalized = [ + { field: 'realm_id', value: 'realm-123' }, + { field: 'deleted_at', operator: 'is_null' } + ] + + const spec: QuerySpec = { + pagination: { + type: 'offset', + offset: 0, + limit: 10 + }, + filters: [ + { field: 'status', operator: 'eq', value: 'published' } + ], + sorts: null, + fields: null + } + + await (executeCollectionQuery as any)( + supabase, + 'posts', + spec, + { baseFilters } + ) + + const query = supabase._lastQuery + const calls = query._calls + + expect(calls.length).toBeGreaterThanOrEqual(3) + expect(calls[0].method).toBe('eq') + expect(calls[0].args).toEqual(['realm_id', 'realm-123']) + expect(calls[1].method).toBe('is') + expect(calls[1].args).toEqual(['deleted_at', null]) + expect(calls[2].method).toBe('eq') + expect(calls[2].args).toEqual(['status', 'published']) + }) + + it('returns internal server error when Supabase returns error', async () => { + const mockResult = { + data: [], + count: null, + error: { message: 'Database error' } + } + + const supabase = createMockSupabase(mockResult) + + const spec: QuerySpec = { + pagination: { + type: 'offset', + offset: 0, + limit: 10 + }, + filters: null, + sorts: null, + fields: null + } + + const result = await (executeCollectionQuery as any)( + supabase, + 'posts', + spec + ) + + const [body, status] = result as any + + expect(status).toBe(500) + expect(body.detail).toBe('Database error') + expect(body.instance).toBe('database') + }) +}) diff --git a/supabase/functions/_shared/middleware/auth.ts b/supabase/functions/_shared/middleware/auth.ts index 7b09ff1..f63ad1e 100644 --- a/supabase/functions/_shared/middleware/auth.ts +++ b/supabase/functions/_shared/middleware/auth.ts @@ -1,10 +1,10 @@ // middleware/auth.ts import type { Context, MiddlewareHandler, Next } from 'hono' -import type { Session, User } from '@supabase/supabase-js' +import type { User } from '@supabase/supabase-js' -import { createAdminClient, createUserClient } from '#shared/supabase.ts' -import { unauthorized } from '@platform/backend/response/index.ts' +import { createAdminClient, createUserClient } from '../utils/clients.ts' +import { unauthorized } from '../response/mod.ts' import { getLogger } from './correlation.ts' export type AuthUserVariables = { diff --git a/supabase/functions/_shared/middleware/correlation.ts b/supabase/functions/_shared/middleware/correlation.ts index e251fca..1d04f07 100644 --- a/supabase/functions/_shared/middleware/correlation.ts +++ b/supabase/functions/_shared/middleware/correlation.ts @@ -1,65 +1,122 @@ /** - * Hono API Utilities: RFC 7807 Problem Details, Structured Logging, Correlation + * Request Correlation & Distributed Tracing for Hono + LogTape * - * Provides production-grade error handling, request correlation, and structured logging - * for Hono.js APIs. Implements RFC 7807 (Problem Details for HTTP APIs) with extensions - * for validation errors, distributed tracing (W3C Trace Context), and request correlation. + * Provides W3C Trace Context propagation and automatic log correlation for Hono.js APIs. + * Implements W3C Trace Context (traceparent/tracestate headers) for distributed tracing + * and integrates with LogTape's implicit context for automatic correlation ID injection. + * + * Key responsibilities: + * - Extract or generate W3C Trace Context from incoming requests + * - Attach correlation IDs (requestId, traceId, spanId) to LogTape's implicit context + * - Propagate trace context headers to downstream services + * - Set response headers for client-side correlation + * + * @see https://www.w3.org/TR/trace-context/ + * @see https://logtape.org/manual/contexts */ import type { Context } from 'hono' +import { getLogger as logtapeLogger, withContext } from '@logtape/logtape' + /** - * Request correlation context carrying IDs for tracing across services + * Request correlation context carrying IDs for tracing across services. + * + * These IDs follow W3C Trace Context specification and enable: + * - End-to-end request tracing across service boundaries + * - Log correlation within and across services + * - Performance monitoring and debugging of distributed requests */ export interface RequestCorrelation { + /** Unique identifier for this specific request (UUID format) */ requestId: string + + /** + * Trace ID identifying the entire distributed transaction (32 hex chars). + * All spans within the same trace share this ID. + */ traceId: string + + /** + * Span ID for this service's portion of the trace (16 hex chars). + * Each service generates a new span ID for its work. + */ spanId: string + + /** + * Parent span ID if this request is part of a larger trace. + * Used to build the parent-child relationship in distributed traces. + */ parentSpanId?: string + + /** ISO 8601 timestamp when the request entered this service */ timestamp: string } /** - * Structured logger instance attached to request context + * Variables attached to Hono context by correlation middleware. + * Use type augmentation to add these to your app's Variables interface. */ -export interface StructuredLogger { - debug(message: string, data?: Record): void - info(message: string, data?: Record): void - warn(message: string, data?: Record): void - error(message: string, data?: Record): void - fatal(message: string, data?: Record): void -} - export interface CorrelationVariables { + /** Extracted or generated correlation context */ correlation: RequestCorrelation - logger: StructuredLogger + + /** Service name */ + serviceName: string + + /** + * Headers to propagate trace context to downstream services. + * Include these when making HTTP requests to other services. + */ traceHeaders: Headers } /** - * Middleware to extract trace context and attach logger to request context + * Middleware to extract W3C Trace Context and attach correlation IDs to LogTape's implicit context. + * + * This middleware MUST be applied early in the middleware stack (after secureHeaders, before auth). * - * Must be applied early in middleware stack. Attaches: - * - correlation: RequestCorrelation - * - logger: StructuredLogger - * - traceHeaders: Headers to propagate to downstream services + * It performs three key operations: + * 1. Extracts or generates W3C Trace Context (traceId, spanId, parentSpanId) + * 2. Attaches correlation IDs to LogTape's implicit context (all logs automatically include these) + * 3. Sets up headers for propagating trace context to downstream services * - * @param serviceName - Service identifier for logs - * @returns Hono middleware + * After this middleware runs, all LogTape loggers will automatically include: + * - request_id: Unique ID for this request + * - trace_id: Distributed trace identifier + * - span_id: This service's span in the trace + * - parent_span_id: Parent span if this is part of a distributed trace + * - service: Service name for identification + * + * @param serviceName - Name of this service (used in logs and trace context) + * @returns Hono middleware function * * @example + * ```typescript + * // In createApp or your main app setup: * app.use('*', correlationMiddleware('likes-service')) * - * // In handlers: - * const logger = c.get('logger') - * const correlation = c.get('correlation') - * logger.info('Processing request') + * // Later in any handler: + * import { getLogger } from '@logtape/logtape' + * + * const logger = getLogger(['likes-service']) + * logger.info`Processing request` + * // Automatically includes: request_id, trace_id, span_id, service + * + * // Access raw correlation data if needed: + * const correlation = getCorrelation(c) + * console.log(correlation.traceId) + * + * // Propagate to downstream services: + * const headers = getPropagationHeaders(c) + * await fetch('http://other-service/api', { headers }) + * ``` */ export function correlationMiddleware(serviceName: string) { return async (c: Context, next: () => Promise) => { const correlation = extractTraceContext(c) - const logger = createLogger(correlation, serviceName) - + // Create headers for propagating trace context downstream + // These follow W3C Trace Context format for interoperability const traceHeaders = new Headers() traceHeaders.set('x-request-id', correlation.requestId) traceHeaders.set( @@ -70,31 +127,66 @@ export function correlationMiddleware(serviceName: string) { traceHeaders.set('tracestate', `parent=${correlation.parentSpanId}`) } + // Attach to Hono context for handler access c.set('correlation', correlation) - c.set('logger', logger) c.set('traceHeaders', traceHeaders) + c.set('serviceName', serviceName) + - // Set response headers + // Set response header so clients can correlate their requests with our logs c.header('X-Request-ID', correlation.requestId) - await next() + // Use LogTape's implicit context to automatically inject correlation IDs + // into all logs within this request scope (including nested function calls) + await withContext({ + request_id: correlation.requestId, + trace_id: correlation.traceId, + span_id: correlation.spanId, + ...(correlation.parentSpanId && { parent_span_id: correlation.parentSpanId }), + service: serviceName, + }, () => next()) } } - /** - * Generate or extract W3C Trace Context from request headers + * Generate or extract W3C Trace Context from request headers. + * + * Follows W3C Trace Context specification for distributed tracing: * https://w3c.github.io/trace-context/ * - * @param c - Hono context with headers - * @returns Correlation IDs for tracing + * **Priority order:** + * 1. If client provides `traceparent` header, extract traceId and parentSpanId + * - Preserves distributed trace across service boundaries + * - Generates new spanId for this service's work + * 2. If client provides `x-request-id` header, use it but generate new trace + * 3. Otherwise, generate completely new trace context + * + * **traceparent format:** `00---` + * - Version: 00 (current W3C spec version) + * - trace-id: 32 lowercase hex chars (128 bits) + * - parent-span-id: 16 lowercase hex chars (64 bits) + * - flags: 01 = sampled, 00 = not sampled + * + * @param c - Hono context with request headers + * @returns Correlation context for this request * * @example - * // If client provides traceparent header: + * ```typescript + * // Example incoming request with trace context: * // traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 - * // Returns: traceId from header, generates new spanId + * // ^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ ^^ + * // | trace-id (32 chars) parent-span flags * - * // If no header, generates new trace and span + * const correlation = extractTraceContext(c) + * // Returns: + * // { + * // requestId: "auto-generated-uuid", + * // traceId: "4bf92f3577b34da6a3ce929d0e0e4736", // from header + * // spanId: "a1b2c3d4e5f67890", // generated for this service + * // parentSpanId: "00f067aa0ba902b7", // from header + * // timestamp: "2025-01-14T12:34:56.789Z" + * // } + * ``` */ export function extractTraceContext(c: Context): RequestCorrelation { const timestamp = new Date().toISOString() @@ -104,23 +196,24 @@ export function extractTraceContext(c: Context): RequestCorrelation { if (traceparent) { try { + // Parse: 00--- const parts = traceparent.split('-') if (parts.length >= 4) { const [_version, traceId, parentSpanId, _flags] = parts return { requestId: c.req.header('x-request-id') || crypto.randomUUID(), traceId, - spanId: generateSpanId(), + spanId: generateSpanId(), // Generate new span for this service parentSpanId, timestamp, } } } catch { - // Fall through to generate new trace + // Invalid traceparent format, fall through to generate new trace } } - // Fall back to X-Request-ID or generate + // No valid traceparent header, start a new trace const requestId = c.req.header('x-request-id') || crypto.randomUUID() const traceId = generateTraceId() @@ -133,85 +226,58 @@ export function extractTraceContext(c: Context): RequestCorrelation { } /** - * Generate hex string, 36 chars + * Generate a 128-bit trace ID as a 32-character hex string. + * + * W3C Trace Context requires trace IDs to be: + * - 32 lowercase hexadecimal characters + * - 128 bits (16 bytes) of randomness + * - Non-zero (all zeros is invalid) + * + * @returns 32-character hex string (e.g., "4bf92f3577b34da6a3ce929d0e0e4736") */ function generateTraceId(): string { return crypto.randomUUID().replace(/-/g, '') } /** - * Generate 64-bit span ID (hex string, 16 chars) + * Generate a 64-bit span ID as a 16-character hex string. + * + * W3C Trace Context requires span IDs to be: + * - 16 lowercase hexadecimal characters + * - 64 bits (8 bytes) of randomness + * - Non-zero (all zeros is invalid) + * + * @returns 16-character hex string (e.g., "00f067aa0ba902b7") */ function generateSpanId(): string { return Math.random().toString(16).substring(2, 18).padEnd(16, '0') } /** - * Create structured logger instance with correlation context + * Helper to extract correlation context from Hono context. * - * Logs are formatted as JSON with consistent structure including correlation IDs, - * timestamps, and trace context for distributed tracing. + * Provides type-safe access to correlation IDs after correlationMiddleware runs. + * Useful when you need raw access to trace IDs (e.g., for custom integrations). * - * @param correlation - Request correlation context - * @param serviceName - Name of the service for identification - * @returns Logger instance + * @param c - Hono context + * @returns Correlation context containing request/trace/span IDs * * @example - * const logger = createLogger(correlation, 'likes-service') - * logger.info('Like created', { user_id: 'abc', target_id: 'xyz' }) - * // Outputs: { "timestamp": "2025-10-09T...", "level": "info", ..., "request_id": "...", ... } + * ```typescript + * export const handler = (c) => { + * const correlation = getCorrelation(c) + * + * // Use in custom integrations + * await sendToMetrics({ + * traceId: correlation.traceId, + * spanId: correlation.spanId, + * // ... + * }) + * } + * ``` */ -export function createLogger( - correlation: RequestCorrelation, - serviceName: string -): StructuredLogger { - const baseEntry = { - request_id: correlation.requestId, - trace_id: correlation.traceId, - span_id: correlation.spanId, - parent_span_id: correlation.parentSpanId, - service: serviceName, - } - - function log(level: keyof StructuredLogger | (string & {}), message: string, data?: Record) { - const entry = { - timestamp: new Date().toISOString(), - level, - message, - ...baseEntry, - ...(data && Object.keys(data).length > 0 ? { context: data } : {}), - } - - // Supabase Edge Functions collect via stdout - const _message = entry - switch (level) { - case "debug": - console.debug(_message) - break; - case "info": - console.info(_message) - break; - case "warn": - console.warn(_message) - break; - case "error": - console.error(_message) - break; - case "fatal": - console.error(_message) - break; - default: - console.log(_message) - } - } - - return { - debug: (msg, data) => log('debug', msg, data), - info: (msg, data) => log('info', msg, data), - warn: (msg, data) => log('warn', msg, data), - error: (msg, data) => log('error', msg, data), - fatal: (msg, data) => log('fatal', msg, data), - } +export function getCorrelation(c: Context): RequestCorrelation { + return c.get('correlation') as RequestCorrelation } /** @@ -228,33 +294,50 @@ export function createLogger( * logger.info('Processing request') * } */ -export function getLogger(c: Context): StructuredLogger { - return c.get('logger') as StructuredLogger +export function getLogger(c: Context, categories?: Parameters[0]) { + const serviceName: string = c.get('serviceName') + if (typeof categories === "string") + return logtapeLogger(["service", serviceName, categories]) + else if (Array.isArray(categories)) + return logtapeLogger(["service", serviceName, ...categories]) + return logtapeLogger(["service", serviceName]) } -/** - * Helper to extract correlation context from Hono context - * - * @param c - Hono context - * @returns Correlation IDs for tracing - */ -export function getCorrelation(c: Context): RequestCorrelation { - return c.get('correlation') as RequestCorrelation -} /** - * Helper to get headers for downstream service calls + * Helper to get headers for propagating trace context to downstream services. * - * Includes correlation and trace context headers for propagation. + * Returns headers that include: + * - `x-request-id`: Request identifier for correlation + * - `traceparent`: W3C Trace Context header with trace/span IDs + * - `tracestate`: Optional vendor-specific trace state + * + * Include these headers when making HTTP requests to other services to maintain + * trace continuity across service boundaries. * * @param c - Hono context - * @returns Headers ready to use in fetch/requests + * @returns Headers object ready to use in fetch/HTTP clients * * @example - * const headers = getPropagationHeaders(c) - * const response = await fetch('http://other-service/api/data', { - * headers: new Headers({ ...headers, Authorization: token }) - * }) + * ```typescript + * // Propagate trace context to downstream service + * export const handler = async (c) => { + * const headers = getPropagationHeaders(c) + * + * const response = await fetch('http://other-service/api/data', { + * headers: { + * ...headers, + * 'Authorization': `Bearer ${token}`, + * 'Content-Type': 'application/json', + * } + * }) + * + * // The downstream service can now: + * // 1. Extract the traceparent header to continue the trace + * // 2. See this request as a child span in distributed tracing tools + * // 3. Correlate its logs with this service's logs via trace_id + * } + * ``` */ export function getPropagationHeaders(c: Context): Record { const traceHeaders = c.get('traceHeaders') as Headers diff --git a/supabase/functions/_shared/middleware/validation.ts b/supabase/functions/_shared/middleware/validation.ts index e66682d..2dcb10e 100644 --- a/supabase/functions/_shared/middleware/validation.ts +++ b/supabase/functions/_shared/middleware/validation.ts @@ -4,14 +4,14 @@ import type { StandardSchemaV1 } from '@standard-schema/spec' import type { MiddlewareHandler, ValidationTargets } from 'hono' -import type { ValidationErrorDetail } from '@platform/backend/response/schemas.ts' +import type { ValidationErrorDetail } from '../response/schemas.ts' import { sValidator } from '@hono/standard-validator' import { HTTPException } from 'hono/http-exception' import { ZodError } from 'zod' import { getLogger } from './correlation.ts' -import { internalServerError, validationFailed } from '@platform/backend/response/index.ts' +import { internalServerError, validationFailed } from '../response/mod.ts' import { getDotPath } from '@standard-schema/utils' @@ -55,7 +55,7 @@ export function createValidator< return async (c, next) => { try { // Important: await so we catch async rejections - await validator(c, next) + return await validator(c, next) } catch (err) { // If we already turned it into an HTTPException, let it bubble if (err instanceof HTTPException) { diff --git a/packages/backend/src/query/fields.ts b/supabase/functions/_shared/query/fields.ts similarity index 76% rename from packages/backend/src/query/fields.ts rename to supabase/functions/_shared/query/fields.ts index 623530d..af724a3 100644 --- a/packages/backend/src/query/fields.ts +++ b/supabase/functions/_shared/query/fields.ts @@ -13,7 +13,7 @@ import { z } from 'zod' -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '../endpoint/schemas.ts' +import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '#shared/server/schemas.ts' import { FieldSelectionNormalizedSchema, type FieldsConfig, @@ -130,29 +130,16 @@ function detectFieldSyntax(data: Record): FieldSelectionNormali /** * Encode field selection back to wire format - * Used for round-trip serialization - * - * @param selection Normalized field selection - * @returns Wire format object - * - * @example - * encodeFieldSelection({ type: 'simple', fields: ['id', 'name'] }) - * // => { fields: 'id,name' } - * - * encodeFieldSelection({ - * type: 'jsonapi', - * fields: { products: ['id', 'name'], categories: ['name'] } - * }) - * // => { 'fields[products]': 'id,name', 'fields[categories]': 'name' } + * Used for round-trip testing */ -function encodeFieldSelection(selection: FieldSelectionNormalized | null): Record { +function encodeFieldSyntax(selection: FieldSelectionNormalized | null): Record { if (!selection) return {} if (selection.type === 'simple') { return { fields: selection.fields.join(',') } } - // JSON:API format + // JSON:API syntax const result: Record = {} for (const [type, fields] of Object.entries(selection.fields)) { result[`fields[${type}]`] = fields.join(',') @@ -161,11 +148,11 @@ function encodeFieldSelection(selection: FieldSelectionNormalized | null): Recor } // ============================================================================ -// SOURCE ADAPTERS +// SOURCE ADAPTERS (unidirectional - decode only) // ============================================================================ /** - * Query parameter adapter (uses z.codec) + * Query parameter adapter (codec for round-trip testing) * Supports: ?fields=a,b,c or ?fields[products]=a,b,c * * @example @@ -181,24 +168,29 @@ function encodeFieldSelection(selection: FieldSelectionNormalized | null): Recor * 'fields[categories]': 'name' * }) * // => { type: 'jsonapi', fields: { products: ['id', 'name'], categories: ['name'] } } + * + * // Encode (for testing) + * const wire = adapter.encode(simple) + * // => { fields: 'id,name,price' } */ export function createFieldsQueryAdapter() { return z.codec( - FieldsQueryWire, // Input (wire) - FieldSelectionNormalizedSchema.nullable(), // Output (normalized) + FieldsQueryWire, + FieldSelectionNormalizedSchema.nullable(), { - decode: (raw) => { + decode: (raw): FieldSelectionNormalized | null => { return detectFieldSyntax(raw) }, - encode: (normalized) => { - return encodeFieldSelection(normalized) + encode: (selection): z.input => { + return encodeFieldSyntax(selection) } } ) } + /** - * JSON body adapter (uses z.codec) + * JSON body adapter (codec for round-trip testing) * Expects: { fields: { type: 'simple', fields: ['a', 'b'] } } * * @example @@ -210,21 +202,21 @@ export function createFieldsQueryAdapter() { */ export function createFieldsJsonAdapter() { return z.codec( - FieldsJsonWire, // Input (wire) - FieldSelectionNormalizedSchema.nullable(), // Output (normalized) + FieldsJsonWire, + FieldSelectionNormalizedSchema.nullable(), { - decode: (raw) => { + decode: (raw): FieldSelectionNormalized | null => { return raw.fields as FieldSelectionNormalized | null }, - encode: (normalized) => { - return { fields: normalized } + encode: (selection): z.input => { + return { fields: selection } } } ) } /** - * FormData adapter (uses z.codec) + * FormData adapter (codec for round-trip testing) * Supports: fields=a,b,c or fields[products]=a,b,c * * @example @@ -236,8 +228,8 @@ export function createFieldsJsonAdapter() { */ export function createFieldsFormAdapter() { return z.codec( - FieldsFormWire, // Input (wire) - FieldSelectionNormalizedSchema.nullable(), // Output (normalized) + FieldsFormWire, + FieldSelectionNormalizedSchema.nullable(), { decode: (raw): FieldSelectionNormalized | null => { // Convert FormValue to plain record for detectFieldSyntax @@ -249,20 +241,20 @@ export function createFieldsFormAdapter() { return detectFieldSyntax(plainObj) }, - encode: (normalized) => { - return encodeFieldSelection(normalized) + encode: (selection): z.input => { + return encodeFieldSyntax(selection) } } ) } + // ============================================================================ // SCHEMA COMPOSITION WITH VALIDATION // ============================================================================ /** * Create endpoint-specific fields schema with validation - * All validation happens in .superRefine() so middleware handles errors * * @param config Configuration for field selection validation * @param config.source Input source type ('query' | 'json' | 'form') @@ -274,7 +266,7 @@ export function createFieldsFormAdapter() { * const schema = createFieldsSchema({ * source: 'query', * allowedFields: ['id', 'name', 'price', 'created_at'], - * defaultFields: ['id', 'name'] + * defaults: ['id', 'name'] * }) * * // Parse and validate @@ -292,30 +284,31 @@ export function createFieldsFormAdapter() { export function createFieldsSchema(config: { source: 'query' | 'json' | 'form' } & FieldsConfig) { - // When disabled, always return null - if (config.disabled) { - return z.null() - } - const adapter = config.source === 'query' ? createFieldsQueryAdapter() : config.source === 'json' ? createFieldsJsonAdapter() : createFieldsFormAdapter() + if (config.disabled) { + return adapter.transform(() => null) + } + return adapter + // Step 1: Apply defaults .transform((selection): FieldSelectionNormalized | null => { - // Apply defaults when no selection provided if (!selection && config.defaults && config.defaults.length > 0) { return { type: 'simple', - fields: [...config.defaults] // Copy to avoid mutation + fields: [...config.defaults] } } return selection }) - .superRefine((selection, ctx) => { - // No selection - use default or allow null - if (!selection) { return; } + + // Step 2: Validate + .check((ctx) => { + const selection = ctx.value + if (!selection) return // Get fields to validate based on selection type const fieldsToValidate = @@ -328,20 +321,10 @@ export function createFieldsSchema(config: { // De-duplicate fields const uniqueFields = Array.from(new Set(fieldsToValidate)) - // Handle wildcard - const hasWildcard = uniqueFields.includes('*') - if (hasWildcard) { - // Wildcard with no restrictions + // Handle wildcard - skip validation, will expand in transform + if (uniqueFields.includes('*')) { if (!config.allowedFields) return - - // Replace wildcard with all allowed fields - if (selection.type === 'simple') { - selection.fields = [...config.allowedFields] - } else if (config.resourceType) { - selection.fields[config.resourceType] = [...config.allowedFields] - } - - return + return // Wildcard expansion happens in transform } // Validate against allowlist @@ -349,23 +332,52 @@ export function createFieldsSchema(config: { const invalidFields = uniqueFields.filter(f => !config.allowedFields!.includes(f)) if (invalidFields.length > 0) { - ctx.addIssue({ + ctx.issues.push({ code: "custom", path: selection.type === 'simple' ? ['fields'] : ['fields', config.resourceType ?? '*'], - message: `Invalid fields: ${invalidFields.join(', ')}. Allowed fields: ${config.allowedFields.join(', ')}` + message: `Invalid fields: ${invalidFields.join(', ')}. Allowed fields: ${config.allowedFields.join(', ')}`, + input: selection }) - return } } + }) + + // Step 3: Transform (expand wildcards, deduplicate) + .transform((selection): FieldSelectionNormalized | null => { + if (!selection) return null - // De-duplicate fields in the selection if (selection.type === 'simple') { - selection.fields = uniqueFields - } else { - // For JSON:API, de-duplicate each resource type - for (const [type, fields] of Object.entries(selection.fields)) { - selection.fields[type] = Array.from(new Set(fields)) + const uniqueFields = Array.from(new Set(selection.fields)) + + // Expand wildcard + if (uniqueFields.includes('*') && config.allowedFields) { + return { + type: 'simple', + fields: [...config.allowedFields] + } } + + return { + type: 'simple', + fields: uniqueFields + } + } + + // JSON:API - deduplicate each resource type, expand wildcards + const deduped: Record = {} + for (const [type, fields] of Object.entries(selection.fields)) { + const unique = Array.from(new Set(fields)) + + if (unique.includes('*') && config.allowedFields) { + deduped[type] = [...config.allowedFields] + } else { + deduped[type] = unique + } + } + + return { + type: 'jsonapi', + fields: deduped } }) -} \ No newline at end of file +} diff --git a/supabase/functions/_shared/query/fields_test.ts b/supabase/functions/_shared/query/fields_test.ts new file mode 100644 index 0000000..ef8ab58 --- /dev/null +++ b/supabase/functions/_shared/query/fields_test.ts @@ -0,0 +1,623 @@ +// utils/query/fields_test.ts +/** + * Comprehensive unit tests for field selection (sparse fieldsets) utilities + * + * Test Structure (Effect-TS inspired - precise behavioral contracts): + * + * 1. SYNTAX DETECTION + * - Simple syntax: ?fields=a,b,c + * - JSON:API syntax: ?fields[type]=a,b,c + * - Priority and fallback + * + * 2. SOURCE ADAPTERS (decode/encode round-trips) + * - createFieldsQueryAdapter + * - createFieldsJsonAdapter + * - createFieldsFormAdapter + * + * 3. VALIDATION (createFieldsSchema) + * - Field allowlist + * - Wildcard expansion + * - De-duplication + * - Defaults + */ + +import { describe, it } from '@std/testing/bdd' +import { expect } from '@std/expect' + +import { + createFieldsQueryAdapter, + createFieldsJsonAdapter, + createFieldsFormAdapter, + createFieldsSchema, +} from './fields.ts' + +import type { FieldSelectionNormalized } from './schemas.ts' + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +const TEST_ALLOWED_FIELDS = ['id', 'name', 'email', 'created_at', 'status', 'price'] + +// ============================================================================ +// 1. SYNTAX DETECTION (via query adapter) +// ============================================================================ + +describe('syntax detection via query adapter', () => { + const adapter = createFieldsQueryAdapter() + + describe('simple syntax', () => { + it('parses fields=a,b,c format', () => { + const result = adapter.decode({ fields: 'id,name,email' }) + + expect(result).toEqual({ + type: 'simple', + fields: ['id', 'name', 'email'] + }) + }) + + it('handles whitespace around field names', () => { + const result = adapter.decode({ fields: ' id , name , email ' }) + + expect(result).toEqual({ + type: 'simple', + fields: ['id', 'name', 'email'] + }) + }) + + it('filters empty segments', () => { + const result = adapter.decode({ fields: 'id,,name' }) + + expect(result?.fields).toEqual(['id', 'name']) + }) + + it('returns null for empty fields value', () => { + const result = adapter.decode({ fields: '' }) + + expect(result).toBeNull() + }) + }) + + describe('JSON:API syntax', () => { + it('parses fields[type]=a,b,c format', () => { + const result = adapter.decode({ 'fields[products]': 'id,name,price' }) + + expect(result).toEqual({ + type: 'jsonapi', + fields: { + products: ['id', 'name', 'price'] + } + }) + }) + + it('handles multiple resource types', () => { + const result = adapter.decode({ + 'fields[products]': 'id,name', + 'fields[categories]': 'id,title' + }) + + expect(result).toEqual({ + type: 'jsonapi', + fields: { + products: ['id', 'name'], + categories: ['id', 'title'] + } + }) + }) + + it('handles whitespace in JSON:API values', () => { + const result = adapter.decode({ 'fields[products]': ' id , name ' }) + + expect(result?.type).toBe('jsonapi') + if (result?.type === 'jsonapi') { + expect(result.fields.products).toEqual(['id', 'name']) + } + }) + }) + + describe('priority and fallback', () => { + it('prefers JSON:API syntax when both present', () => { + const result = adapter.decode({ + fields: 'simple_field', + 'fields[products]': 'jsonapi_field' + }) + + expect(result?.type).toBe('jsonapi') + }) + + it('falls back to simple syntax when no JSON:API', () => { + const result = adapter.decode({ + fields: 'id,name', + other_param: 'value' + }) + + expect(result?.type).toBe('simple') + }) + + it('returns null when no fields params', () => { + const result = adapter.decode({ limit: '20', offset: '0' }) + + expect(result).toBeNull() + }) + }) + + describe('edge cases', () => { + it('handles undefined input gracefully', () => { + const result = adapter.decode({}) + + expect(result).toBeNull() + }) + + it('handles array values (ZStringOrStringArray)', () => { + const result = adapter.decode({ fields: ['id,name', 'ignored'] }) + + // Should use first array element + expect(result?.type).toBe('simple') + if (result?.type === 'simple') { + expect(result.fields).toContain('id') + expect(result.fields).toContain('name') + } + }) + }) +}) + +// ============================================================================ +// 2. SOURCE ADAPTERS +// ============================================================================ + +describe('createFieldsQueryAdapter', () => { + const adapter = createFieldsQueryAdapter() + + describe('decode', () => { + it('parses simple fields parameter', () => { + const result = adapter.decode({ fields: 'id,name' }) + + expect(result?.type).toBe('simple') + }) + + it('parses JSON:API fields parameters', () => { + const result = adapter.decode({ 'fields[users]': 'id,email' }) + + expect(result?.type).toBe('jsonapi') + }) + + it('returns null when no fields', () => { + const result = adapter.decode({ page: '1' }) + + expect(result).toBeNull() + }) + }) + + describe('encode', () => { + it('encodes simple selection to fields=a,b', () => { + const result = adapter.encode({ + type: 'simple', + fields: ['id', 'name', 'email'] + }) + + expect(result).toEqual({ fields: 'id,name,email' }) + }) + + it('encodes JSON:API selection to fields[type]=a,b', () => { + const result = adapter.encode({ + type: 'jsonapi', + fields: { + products: ['id', 'name'], + categories: ['id', 'title'] + } + }) + + expect(result).toEqual({ + 'fields[products]': 'id,name', + 'fields[categories]': 'id,title' + }) + }) + + it('returns empty object for null', () => { + const result = adapter.encode(null) + + expect(result).toEqual({}) + }) + }) + + describe('round-trip', () => { + it('preserves simple selection', () => { + const original: FieldSelectionNormalized = { + type: 'simple', + fields: ['id', 'name', 'email'] + } + + const encoded = adapter.encode(original) + const decoded = adapter.decode(encoded) + + expect(decoded).toEqual(original) + }) + + it('preserves JSON:API selection', () => { + const original: FieldSelectionNormalized = { + type: 'jsonapi', + fields: { + products: ['id', 'name'], + categories: ['id', 'title'] + } + } + + const encoded = adapter.encode(original) + const decoded = adapter.decode(encoded) + + expect(decoded).toEqual(original) + }) + }) +}) + +describe('createFieldsJsonAdapter', () => { + const adapter = createFieldsJsonAdapter() + + describe('decode', () => { + it('extracts fields from { fields: {...} }', () => { + const result = adapter.decode({ + fields: { type: 'simple', fields: ['id', 'name'] } + }) + + expect(result).toEqual({ type: 'simple', fields: ['id', 'name'] }) + }) + + it('returns null when fields missing', () => { + const result = adapter.decode({}) + + expect(result).toBeNull() + }) + }) + + describe('encode', () => { + it('wraps selection in { fields: {...} }', () => { + const result = adapter.encode({ type: 'simple', fields: ['id'] }) + + expect(result).toEqual({ + fields: { type: 'simple', fields: ['id'] } + }) + }) + }) +}) + +describe('createFieldsFormAdapter', () => { + const adapter = createFieldsFormAdapter() + + describe('decode', () => { + it('parses simple fields from form data', () => { + const result = adapter.decode({ fields: 'id,name' }) + + expect(result?.type).toBe('simple') + }) + + it('parses JSON:API fields from form data', () => { + const result = adapter.decode({ 'fields[users]': 'id,email' }) + + expect(result?.type).toBe('jsonapi') + }) + }) + + describe('encode', () => { + it('encodes to form data format', () => { + const result = adapter.encode({ type: 'simple', fields: ['id', 'name'] }) + + expect(result).toEqual({ fields: 'id,name' }) + }) + }) +}) + +// ============================================================================ +// 3. VALIDATION (createFieldsSchema) +// ============================================================================ + +describe('createFieldsSchema', () => { + describe('field allowlist', () => { + it('allows fields in allowedFields', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS + }) + + const result = schema.safeParse({ fields: 'id,name,email' }) + + expect(result.success).toBe(true) + }) + + it('rejects fields not in allowedFields', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: ['id', 'name'] + }) + + const result = schema.safeParse({ fields: 'id,unknown_field' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('Invalid fields') + expect(result.error.issues[0].message).toContain('unknown_field') + } + }) + + it('allows any field when allowedFields undefined', () => { + // Note: undefined and empty array [] both mean "no restrictions". + // This is intentional semantic equivalence for API flexibility. + const schema = createFieldsSchema({ + source: 'query' + }) + + const result = schema.safeParse({ fields: 'any,field,name' }) + + expect(result.success).toBe(true) + }) + + it('provides helpful error listing allowed fields', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: ['id', 'name'] + }) + + const result = schema.safeParse({ fields: 'invalid' }) + + expect(result.success).toBe(false) + if (!result.success) { + const msg = result.error.issues[0].message + expect(msg).toContain('id') + expect(msg).toContain('name') + } + }) + }) + + describe('wildcard expansion', () => { + it('expands * to all allowed fields', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: ['id', 'name', 'email'] + }) + + const result = schema.safeParse({ fields: '*' }) + + expect(result.success).toBe(true) + if (result.success && result.data?.type === 'simple') { + expect(result.data.fields).toEqual(['id', 'name', 'email']) + } + }) + + it('passes through wildcard when no allowedFields defined', () => { + // When no allowedFields is configured, wildcard cannot be expanded + // to a list - it passes through as-is for the handler to interpret + const schema = createFieldsSchema({ + source: 'query' + }) + + const result = schema.safeParse({ fields: '*' }) + + expect(result.success).toBe(true) + if (result.success && result.data?.type === 'simple') { + // Behavior: '*' is kept as literal since we can't expand it + expect(result.data.fields).toContain('*') + } + }) + + it('expands JSON:API wildcard for specific resource type', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: ['id', 'name'], + resourceType: 'users' + }) + + const result = schema.safeParse({ 'fields[users]': '*' }) + + expect(result.success).toBe(true) + if (result.success && result.data?.type === 'jsonapi') { + expect(result.data.fields.users).toEqual(['id', 'name']) + } + }) + }) + + describe('de-duplication', () => { + it('removes duplicate fields', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS + }) + + const result = schema.safeParse({ fields: 'id,name,id,name' }) + + expect(result.success).toBe(true) + if (result.success && result.data?.type === 'simple') { + expect(result.data.fields).toEqual(['id', 'name']) + } + }) + + it('de-duplicates each resource type in JSON:API', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS + }) + + const result = schema.safeParse({ + 'fields[products]': 'id,name,id' + }) + + expect(result.success).toBe(true) + if (result.success && result.data?.type === 'jsonapi') { + expect(result.data.fields.products).toEqual(['id', 'name']) + } + }) + }) + + describe('defaults', () => { + it('uses defaults when no fields provided', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + defaults: ['id', 'name'] + }) + + const result = schema.safeParse({}) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data?.type).toBe('simple') + if (result.data?.type === 'simple') { + expect(result.data.fields).toEqual(['id', 'name']) + } + } + }) + + it('ignores defaults when fields provided', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + defaults: ['id', 'name'] + }) + + const result = schema.safeParse({ fields: 'email' }) + + expect(result.success).toBe(true) + if (result.success && result.data?.type === 'simple') { + expect(result.data.fields).toEqual(['email']) + } + }) + + it('returns null when no fields and no defaults', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS + }) + + const result = schema.safeParse({}) + + expect(result.success).toBe(true) + expect(result.data).toBeNull() + }) + }) + + describe('disabled mode', () => { + it('returns null when disabled=true', () => { + const schema = createFieldsSchema({ + source: 'query', + disabled: true + }) + + const result = schema.safeParse({ fields: 'id,name' }) + + expect(result.success).toBe(true) + expect(result.data).toBeNull() + }) + }) + + describe('JSON:API resource type filtering', () => { + it('validates only specified resourceType fields', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: ['id', 'name'], + resourceType: 'users' + }) + + const result = schema.safeParse({ + 'fields[users]': 'id,name', + 'fields[other]': 'anything' // Should be ignored + }) + + expect(result.success).toBe(true) + }) + }) +}) + +// ============================================================================ +// EDGE CASES +// ============================================================================ + +describe('edge cases', () => { + describe('empty and whitespace', () => { + const adapter = createFieldsQueryAdapter() + + it('handles whitespace-only fields value', () => { + const result = adapter.decode({ fields: ' ' }) + + expect(result).toBeNull() + }) + + it('handles empty JSON:API values', () => { + const result = adapter.decode({ 'fields[users]': '' }) + + expect(result).toBeNull() + }) + }) + + describe('special characters in field names', () => { + const adapter = createFieldsQueryAdapter() + + it('handles underscored field names', () => { + const result = adapter.decode({ fields: 'created_at,updated_at' }) + + expect(result?.type).toBe('simple') + if (result?.type === 'simple') { + expect(result.fields).toContain('created_at') + } + }) + + it('handles camelCase field names', () => { + const result = adapter.decode({ fields: 'createdAt,updatedAt' }) + + expect(result?.type).toBe('simple') + if (result?.type === 'simple') { + expect(result.fields).toContain('createdAt') + } + }) + + it('handles dotted field names (nested paths)', () => { + const result = adapter.decode({ fields: 'user.name,user.email' }) + + expect(result?.type).toBe('simple') + if (result?.type === 'simple') { + expect(result.fields).toContain('user.name') + } + }) + }) + + describe('many fields', () => { + it('handles large number of fields', () => { + const schema = createFieldsSchema({ + source: 'query', + allowedFields: Array.from({ length: 100 }, (_, i) => `field${i}`) + }) + + const fields = Array.from({ length: 50 }, (_, i) => `field${i}`).join(',') + const result = schema.safeParse({ fields }) + + expect(result.success).toBe(true) + }) + }) + + describe('unicode field names', () => { + const adapter = createFieldsQueryAdapter() + + it('handles unicode in field names', () => { + const result = adapter.decode({ fields: '名前,メール' }) + + expect(result?.type).toBe('simple') + if (result?.type === 'simple') { + expect(result.fields).toContain('名前') + expect(result.fields).toContain('メール') + } + }) + }) + + describe('validation with empty allowedFields', () => { + it('allows any field when allowedFields is empty array', () => { + // Note: Empty array [] and undefined both mean "no restrictions". + // This is semantic equivalence - see "allows any field when allowedFields undefined" + // in the field allowlist section above. + const schema = createFieldsSchema({ + source: 'query', + allowedFields: [] + }) + + const result = schema.safeParse({ fields: 'anything,goes' }) + + expect(result.success).toBe(true) + }) + }) +}) \ No newline at end of file diff --git a/packages/backend/src/query/filtering.ts b/supabase/functions/_shared/query/filtering.ts similarity index 69% rename from packages/backend/src/query/filtering.ts rename to supabase/functions/_shared/query/filtering.ts index 26532d7..dd59a21 100644 --- a/packages/backend/src/query/filtering.ts +++ b/supabase/functions/_shared/query/filtering.ts @@ -45,7 +45,7 @@ * * ## Where `arrayOperators` matter * - * - Instead of hardcoding that `'in'`/`'nin'` always expect arrays, each field’s registry entry + * - Instead of hardcoding that `'in'`/`'nin'` always expect arrays, each field's registry entry * can *declare* which operators consume arrays via `arrayOperators`. This allows per-field policy * (e.g., `status` allows `in`; `title` does not), and future operators like `between`, `overlaps`, * or geo/json containment to be opt-in for the fields that support them. @@ -57,7 +57,7 @@ * ## Validation layer (what this module enforces) * * - **Allowlist fields & operators per resource**: We require a `FilterRegistry` describing - * what’s filterable and with which operators, including the expected scalar type (string/number/ + * what's filterable and with which operators, including the expected scalar type (string/number/ * boolean/date/enum/uuid) and allowed enum values. * * - **Type-aware coercion**: The validator converts values based on the field type and operator: @@ -78,7 +78,7 @@ * ## Security & performance notes * * - **Security**: Always allowlist fields/operators and type-check values before building queries. - * Don’t ever string-concatenate SQL; use parameterized calls. + * Don't ever string-concatenate SQL; use parameterized calls. * * - **Performance**: Index columns you filter/sort on; prefer compound indexes that align to * common filter+sort patterns. This is especially important for high-cardinality fields and @@ -120,7 +120,7 @@ * only handle source parsing: they normalize different input formats (query strings, JSON bodies, form data) * into a consistent internal shape. * - * - The actual business rules live in `createFiltersSchema(...).superRefine(...)`: + * - The actual business rules live in `createFiltersSchema(...).check(...)`: * • Enforce allowlists (only certain fields/operators allowed) * • Match operators to correct value types * • Split arrays consistently (e.g. "a,b,c" → ["a","b","c"]) @@ -132,7 +132,7 @@ import { z } from 'zod' -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '../endpoint/schemas.ts' +import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '#shared/server/schemas.ts' import { FiltersNormalizedSchema, type FilterNormalized, @@ -221,6 +221,7 @@ function parseBracketNotation(data: Record): FiltersNormalized /** * Encode filters back to bracket notation + * Used for round-trip testing * * @param filters Normalized filters array * @returns Wire format object @@ -268,11 +269,11 @@ function encodeBracketNotation(filters: FiltersNormalized): Record { + decode: (raw): FiltersNormalized => { return parseBracketNotation(raw) }, - encode: (normalized) => { + encode: (normalized): z.input => { return encodeBracketNotation(normalized) } } @@ -302,7 +306,7 @@ export function createFiltersQueryAdapter() { } /** - * JSON body adapter (uses z.codec) + * JSON body adapter (codec for round-trip testing) * Expects: { filters: [{ field: 'category', operator: 'eq', value: 'electronics' }] } * * @example @@ -313,13 +317,13 @@ export function createFiltersQueryAdapter() { */ export function createFiltersJsonAdapter() { return z.codec( - FiltersJsonWire, // Input (wire) - FiltersNormalizedSchema, // Output (normalized) + FiltersJsonWire, + FiltersNormalizedSchema, { - decode: (raw) => { + decode: (raw): FiltersNormalized => { return raw.filters as FiltersNormalized }, - encode: (normalized) => { + encode: (normalized): z.input => { return { filters: normalized } } } @@ -327,7 +331,7 @@ export function createFiltersJsonAdapter() { } /** - * FormData adapter (uses z.codec) + * FormData adapter (codec for round-trip testing) * Supports same bracket notation as query adapter * * @example @@ -338,14 +342,13 @@ export function createFiltersJsonAdapter() { */ export function createFiltersFormAdapter() { return z.codec( - FiltersFormWire, // Input (wire) - FiltersNormalizedSchema, // Output (normalized) + FiltersFormWire, + FiltersNormalizedSchema, { decode: (raw): FiltersNormalized => { - // Convert FormValue to plain record for parseBracketNotation return parseBracketNotation(raw) }, - encode: (normalized) => { + encode: (normalized): z.input => { return encodeBracketNotation(normalized) } } @@ -356,26 +359,34 @@ export function createFiltersFormAdapter() { // VALIDATION HELPERS // ============================================================================ +/** + * Result type for coercion operations + */ +type CoercionResult = + | { ok: true; value: unknown } + | { ok: false } + /** * Validate and coerce operator value based on field type - * Mutates the filter object with coerced value + * Pure function - returns coerced value or adds issues to context * * @param filter Filter to validate and coerce * @param fieldDef Field definition from registry * @param ctx Zod refinement context for error reporting * @param path Path for error reporting + * @returns Coercion result with ok status and optional coerced value */ function validateAndCoerceOperatorValue( filter: FilterNormalized, fieldDef: FilterRegistry[string], - ctx: z.RefinementCtx, + ctx: z.RefinementCtx, path: (string | number)[] -) { +): CoercionResult { const { operator, value, field } = filter // Null operators don't need values if (operator === 'is_null' || operator === 'is_not_null') { - return + return { ok: true, value: undefined } } // Between operator needs exactly 2 values @@ -390,70 +401,77 @@ function validateAndCoerceOperatorValue( } if (arrayValue.length !== 2) { - ctx.addIssue({ + ctx.issues.push({ code: 'custom', - input: ctx.value, + input: filter, path: [...path, 'value'], message: `Operator 'between' on field '${field}' requires exactly 2 values (min,max). Got ${arrayValue.length}.` }) - return + + return { ok: false } } // Coerce based on field type if (fieldDef.type === 'number') { const [min, max] = arrayValue.map(Number) - if (isNaN(min) || isNaN(max)) { - ctx.addIssue({ + if (Number.isNaN(min) || Number.isNaN(max)) { + ctx.issues.push({ code: 'custom', - input: ctx.value, + input: filter, path: [...path, 'value'], message: `Field '${field}' requires numeric values for 'between' operator` }) - return + + return { ok: false } } if (min > max) { - ctx.addIssue({ + ctx.issues.push({ code: 'custom', - input: ctx.value, + input: filter, path: [...path, 'value'], message: `Field '${field}' 'between' operator requires min <= max. Got min=${min}, max=${max}.` }) - return + + return { ok: false } } - filter.value = [min, max] + + return { ok: true, value: [min, max] } } else if (fieldDef.type === 'date') { const [minDate, maxDate] = arrayValue.map(v => new Date(String(v))) - if (isNaN(minDate.getTime()) || isNaN(maxDate.getTime())) { - ctx.addIssue({ + if (Number.isNaN(minDate.getTime()) || Number.isNaN(maxDate.getTime())) { + ctx.issues.push({ code: 'custom', - input: ctx.value, + input: filter, path: [...path, 'value'], message: `Field '${field}' requires valid dates for 'between' operator` }) - return + + return { ok: false } } if (minDate > maxDate) { - ctx.addIssue({ + ctx.issues.push({ code: 'custom', - input: ctx.value, + input: filter, path: [...path, 'value'], message: `Field '${field}' 'between' operator requires start date <= end date.` }) - return + + return { ok: false } } - filter.value = [minDate.toISOString(), maxDate.toISOString()] + return { ok: true, value: [minDate.toISOString(), maxDate.toISOString()] } } else { - ctx.addIssue({ + ctx.issues.push({ code: 'custom', - input: ctx.value, + input: filter, path: [...path, 'operator'], message: `Operator 'between' not supported for field type '${fieldDef.type}' on field '${field}'` }) } - return + + return { ok: false } } // Array operators need special handling @@ -470,18 +488,78 @@ function validateAndCoerceOperatorValue( } if (arrayValue.length === 0) { - ctx.addIssue({ - code: 'too_small', // v4 literal (not the enum) + ctx.issues.push({ + code: 'too_small', minimum: 1, origin: 'array', path: [...path, 'value'], - message: `Operator '${operator}' on field '${field}' requires an array or comma-separated value with at least 1 value.` + message: `Operator '${operator}' on field '${field}' requires an array or comma-separated value with at least 1 value.`, + input: filter }); - return; // stop further processing for this filter + + return { ok: false } } - filter.value = arrayValue - return + // Type-specific coercion for array values + switch (fieldDef.type) { + case 'enum': { + // Validate each value against allowed enum values + if (fieldDef.values) { + const invalidValues = arrayValue.filter(v => !fieldDef.values!.includes(String(v))) + if (invalidValues.length > 0) { + ctx.issues.push({ + code: 'custom', + input: filter, + path: [...path, 'value'], + message: `Invalid values for enum field '${field}': ${invalidValues.map(v => `'${v}'`).join(', ')}. Allowed values: ${fieldDef.values.join(', ')}` + }) + return { ok: false } + } + } + return { ok: true, value: arrayValue.map(String) } + } + + case 'number': { + // Coerce array values to numbers + const coercedNumbers = arrayValue.map(v => Number(v)) + const hasNaN = coercedNumbers.some(n => Number.isNaN(n)) + if (hasNaN) { + ctx.issues.push({ + code: 'custom', + input: filter, + path: [...path, 'value'], + message: `Field '${field}' requires numeric values for operator '${operator}'` + }) + return { ok: false } + } + return { ok: true, value: coercedNumbers } + } + + case 'uuid': { + // Validate each value as UUID + const invalidUuids: unknown[] = [] + for (const v of arrayValue) { + const uuidCheck = z.uuid().safeParse(String(v)) + if (!uuidCheck.success) { + invalidUuids.push(v) + } + } + if (invalidUuids.length > 0) { + ctx.issues.push({ + code: 'custom', + input: filter, + path: [...path, 'value'], + message: `Field '${field}' requires valid UUIDs for operator '${operator}'. Invalid: ${invalidUuids.map(v => `'${v}'`).join(', ')}` + }) + return { ok: false } + } + return { ok: true, value: arrayValue.map(String) } + } + + default: + // String and other types: pass through as string array + return { ok: true, value: arrayValue.map(String) } + } } // Non-array operators - coerce based on field type @@ -489,41 +567,44 @@ function validateAndCoerceOperatorValue( case 'number': if (['gt', 'gte', 'lt', 'lte', 'eq', 'ne'].includes(operator)) { const numValue = Number(value) - if (isNaN(numValue)) { - ctx.addIssue({ + if (Number.isNaN(numValue)) { + ctx.issues.push({ code: "custom", - input: ctx.value, + input: filter, path: [...path, 'value'], message: `Field '${field}' requires numeric value for operator '${operator}'` }) - return + + return { ok: false } } - filter.value = numValue + return { ok: true, value: numValue } } break case 'boolean': if (operator === 'eq' || operator === 'ne') { - const boolValue = value === 'true' || value === '1' || value === 1 - filter.value = boolValue + const boolValue = value === true || value === 'true' || value === '1' || value === 1 + return { ok: true, value: boolValue } } break case 'date': if (['gt', 'gte', 'lt', 'lte', 'eq', 'ne'].includes(operator)) { const dateValue = new Date(String(value)) - if (isNaN(dateValue.getTime())) { - ctx.addIssue({ + + if (Number.isNaN(dateValue.getTime())) { + ctx.issues.push({ code: "custom", - input: ctx.value, + input: filter, path: [...path, 'value'], message: `Field '${field}' requires valid date for operator '${operator}'` }) - return + + return { ok: false } } - filter.value = dateValue.toISOString() + return { ok: true, value: dateValue.toISOString() } } break @@ -531,15 +612,17 @@ function validateAndCoerceOperatorValue( if (operator === 'eq' || operator === 'ne') { const strValue = String(value) if (fieldDef.values && !fieldDef.values.includes(strValue)) { - ctx.addIssue({ + ctx.issues.push({ code: "custom", - input: ctx.value, + input: filter, path: [...path, 'value'], message: `Invalid value '${strValue}' for enum field '${field}'. Allowed values: ${fieldDef.values.join(', ')}` }) - return + return { ok: false } } + + return { ok: true, value: strValue } } break @@ -547,22 +630,39 @@ function validateAndCoerceOperatorValue( if (operator === 'eq' || operator === 'ne') { const uuidCheck = z.uuid().safeParse(String(value)) if (!uuidCheck.success) { - const uuidIssue = uuidCheck.error.issues[0]; - ctx.addIssue({ - ...uuidIssue, - path: [...path, 'value'], - message: `Field '${field}' requires valid UUID for operator '${operator}'. ${uuidIssue.message}` - }) - return + const uuidIssues = uuidCheck.error.issues; + for (const uuidIssue of uuidIssues) { + ctx.issues.push({ + ...uuidIssue, + code: "invalid_type", + expected: "Valid UUID format", + path: [...path, 'value'], + message: `Field '${field}' requires valid UUID for operator '${operator}'. ${uuidIssue.message}`, + input: filter + }) + } + + return { ok: false } } + + return { ok: true, value: String(value) }; } break case 'string': // String operators accept any value, coerce to string - filter.value = String(value) - break + return { ok: true, value: String(value) } } + + // If we fall through, operator/type combo isn't supported. + ctx.issues.push({ + code: "custom", + input: value, + path: [...path, "operator"], + message: + `Operator '${operator}' not supported for field '${field}' (type '${fieldDef.type}').`, + }); + return { ok: false }; } // ============================================================================ @@ -571,7 +671,10 @@ function validateAndCoerceOperatorValue( /** * Create endpoint-specific filters schema with validation - * All validation happens in .superRefine() so middleware handles errors + * + * Note: This schema is unidirectional (decode only) because it adds + * non-reversible transformations (defaults, type coercion). Use the + * adapters directly for round-trip testing. * * @param config Configuration for filter validation * @param config.source Input source type ('query' | 'json' | 'form') @@ -605,17 +708,16 @@ function validateAndCoerceOperatorValue( export function createFiltersSchema(config: { source: 'query' | 'json' | 'form' } & FiltersConfig) { - // When disabled, always return null - if (config.disabled) { - return z.null() - } - // Select appropriate adapter based on source const adapter = config.source === 'query' ? createFiltersQueryAdapter() : config.source === 'json' ? createFiltersJsonAdapter() : createFiltersFormAdapter() + if (config.disabled) { + return adapter.transform(() => null) + } + const maxFilters = config.limits?.maxFilters ?? 20 const mergeDefaults = config.mergeDefaults ?? true @@ -623,70 +725,96 @@ export function createFiltersSchema(config: { const allowedFields = Object.keys(registry) return adapter + // Step 1: Apply defaults (pure transform) .transform((filters): FiltersNormalized => { // Apply default filters - if (config.defaults && config.defaults.length > 0) { - if (filters.length === 0) { - // No user filters - use defaults - return [...config.defaults] - } else if (mergeDefaults) { - // Merge defaults with user filters - // Defaults come first (applied before user filters) - return [...config.defaults, ...filters] - } + if (config.defaults?.length && filters.length === 0) { + // No user filters - use defaults + return [...config.defaults] + } + + if (mergeDefaults && config.defaults?.length) { + // Merge defaults with user filters + // Defaults come first (applied before user filters) + return [...config.defaults, ...filters] } return filters }) - .superRefine((filters, ctx) => { + + // Step 2: Validate structure (no mutations) + .check((ctx) => { + const filters = ctx.value + // Check filter count (DoS protection) if (filters.length > maxFilters) { - ctx.addIssue({ + ctx.issues.push({ code: "too_big", maximum: maxFilters, origin: 'array', path: [], - message: `Too many filters: maximum ${maxFilters} allowed, got ${filters.length}` + message: `Too many filters: maximum ${maxFilters} allowed, got ${filters.length}`, + input: filters }) return // Don't continue validating individual filters } // No registry = skip field/operator validation - if (!config.registry) { - return - } + if (!config.registry) return // Validate each filter filters.forEach((filter, idx) => { - const { field, operator = 'eq', value } = filter + const { field, operator = 'eq' } = filter + const fieldDef = registry[field] // Check if field is filterable - const fieldDef = registry[field] if (!fieldDef && allowedFields.length > 0) { - ctx.addIssue({ + ctx.issues.push({ code: "custom", - input: ctx.value, + input: filter, path: [idx, 'field'], - message: `Field '${field}' is not filterable. Allowed fields: ${allowedFields.join(', ')}` + message: `Field '${field}' is not filterable. Allowed fields: ${allowedFields.join(', ')}`, }) return // Skip further validation for this filter } // If field is in registry, validate operator - if (fieldDef) { - if (!fieldDef.operators.includes(operator)) { - ctx.addIssue({ - code: "custom", - input: ctx.value, - path: [idx, 'operator'], - message: `Operator '${operator}' not allowed for field '${field}'. Allowed operators: ${fieldDef.operators.join(', ')}` - }) - return - } - - // Validate operator-value type compatibility and coerce - validateAndCoerceOperatorValue(filter, fieldDef, ctx, [idx]) + if (fieldDef && !fieldDef.operators.includes(operator)) { + ctx.issues.push({ + code: "custom", + input: filter, + path: [idx, 'operator'], + message: `Operator '${operator}' not allowed for field '${field}'. Allowed operators: ${fieldDef.operators.join(', ')}` + }) + return } + }) - }) -} \ No newline at end of file + }) + + // Step 3: Coerce values (pure transform, only runs on valid data) + .transform((filters, ctx): FiltersNormalized => { + if (!config.registry) return filters + + // If earlier .check() already recorded issues, skip coercion + if (ctx.issues.length > 0) return z.NEVER + + const issuesBefore = ctx.issues.length + const coerced = filters.map((filter, idx) => { + const fieldDef = config.registry![filter.field] + if (!fieldDef) return filter + + const result = validateAndCoerceOperatorValue(filter, fieldDef, ctx, [idx]) + + // Placeholder return; we will abort below if any issues were added. + if (!result.ok) return filter + + return { ...filter, value: result.value } + }) + + // If coercion added any issues, abort and let Zod surface them. + if (ctx.issues.length !== issuesBefore) return z.NEVER + + return coerced + }) +} diff --git a/supabase/functions/_shared/query/filtering_test.ts b/supabase/functions/_shared/query/filtering_test.ts new file mode 100644 index 0000000..573235f --- /dev/null +++ b/supabase/functions/_shared/query/filtering_test.ts @@ -0,0 +1,1649 @@ +// utils/query/filtering_test.ts +/** + * Comprehensive unit tests for filtering utilities + * + * Test Structure (Effect-TS inspired - precise behavioral contracts): + * + * 1. BRACKET NOTATION PARSING + * - parseBracketNotation() - URL query extraction + * - Field extraction, operator inference, null keywords + * + * 2. SOURCE ADAPTERS (decode/encode round-trips) + * - createFiltersQueryAdapter + * - createFiltersJsonAdapter + * - createFiltersFormAdapter + * + * 3. TYPE COERCION (validateAndCoerceOperatorValue) + * - Number coercion and NaN rejection + * - Boolean coercion + * - Date coercion and invalid date rejection + * - Enum validation + * - UUID validation + * - Array operator splitting + * + * 4. VALIDATION (createFiltersSchema) + * - DoS protection (maxFilters) + * - Field allowlist + * - Operator allowlist per field + * - Defaults and merging + */ + +import { describe, it } from '@std/testing/bdd' +import { expect } from '@std/expect' + +import { + createFiltersQueryAdapter, + createFiltersJsonAdapter, + createFiltersFormAdapter, + createFiltersSchema, +} from './filtering.ts' + +import type { FilterRegistry, FiltersNormalized } from './schemas.ts' + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +/** + * Sample filter registry covering all field types + */ +const TEST_REGISTRY: FilterRegistry = { + status: { + operators: ['eq', 'ne', 'in', 'nin'], + type: 'enum', + values: ['draft', 'published', 'archived'], + arrayOperators: ['in', 'nin'] + }, + price: { + operators: ['eq', 'gt', 'gte', 'lt', 'lte'], + type: 'number' + }, + created_at: { + operators: ['eq', 'gt', 'gte', 'lt', 'lte'], + type: 'date' + }, + is_featured: { + operators: ['eq', 'ne'], + type: 'boolean' + }, + user_id: { + operators: ['eq'], + type: 'uuid' + }, + title: { + operators: ['eq', 'contains', 'icontains', 'startswith', 'endswith'], + type: 'string' + }, + deleted_at: { + operators: ['is_null', 'is_not_null'], + type: 'date' + } +} + +function makeQueryFilters(count: number, prefix = 'field'): Record { + return Object.fromEntries( + Array.from({ length: count }, (_, i) => [`filter[${prefix}${i}][eq]`, `value${i}`]) + ) +} + +/** + * Factory for creating filter fixtures + */ +function makeFilter(overrides: Partial = {}): FiltersNormalized[0] { + return { + field: 'status', + operator: 'eq', + value: 'published', + ...overrides + } +} + +// ============================================================================ +// 1. BRACKET NOTATION PARSING +// ============================================================================ + +describe('bracket notation parsing via query adapter', () => { + const adapter = createFiltersQueryAdapter() + + describe('field extraction', () => { + it('extracts field from filter[field]=value', () => { + const result = adapter.decode({ 'filter[status]': 'published' }) + + expect(result).toHaveLength(1) + expect(result[0].field).toBe('status') + }) + + it('extracts field from filter[field][operator]=value', () => { + const result = adapter.decode({ 'filter[price][gte]': '50' }) + + expect(result).toHaveLength(1) + expect(result[0].field).toBe('price') + }) + + it('ignores non-filter keys', () => { + const result = adapter.decode({ + 'filter[status]': 'published', + 'sort': 'created_at:desc', + 'limit': '20' + }) + + expect(result).toHaveLength(1) + expect(result[0].field).toBe('status') + }) + + it('handles multiple filters', () => { + const result = adapter.decode({ + 'filter[status]': 'published', + 'filter[price][gte]': '50', + 'filter[price][lte]': '100' + }) + + expect(result).toHaveLength(3) + expect(result.map(f => f.field)).toEqual(['status', 'price', 'price']) + }) + + it('handles underscore field names', () => { + const result = adapter.decode({ 'filter[created_at]': '2024-01-01' }) + + expect(result[0].field).toBe('created_at') + }) + + it('handles field names with numbers', () => { + const result = adapter.decode({ 'filter[field1]': 'value' }) + + expect(result[0].field).toBe('field1') + }) + }) + + describe('operator inference', () => { + it('defaults to eq when no operator specified', () => { + const result = adapter.decode({ 'filter[status]': 'published' }) + + expect(result[0].operator).toBe('eq') + }) + + it('extracts explicit operator from second bracket', () => { + const result = adapter.decode({ 'filter[price][gte]': '50' }) + + expect(result[0].operator).toBe('gte') + }) + + const validOperators = [ + 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', + 'in', 'nin', 'contains', 'icontains', + 'startswith', 'endswith' + ] as const + + validOperators.forEach(op => { + it(`accepts operator: ${op}`, () => { + const result = adapter.decode({ [`filter[field][${op}]`]: 'value' }) + + expect(result[0].operator).toBe(op) + }) + }) + }) + + describe('null keyword handling', () => { + it('converts value "null" to operator is_null', () => { + const result = adapter.decode({ 'filter[deleted_at]': 'null' }) + + expect(result[0].operator).toBe('is_null') + expect(result[0].value).toBeUndefined() + }) + + it('converts value "not_null" to operator is_not_null', () => { + const result = adapter.decode({ 'filter[deleted_at]': 'not_null' }) + + expect(result[0].operator).toBe('is_not_null') + expect(result[0].value).toBeUndefined() + }) + }) + + describe('array value extraction', () => { + it('takes first value when ZStringOrStringArray provides array', () => { + const result = adapter.decode({ 'filter[status]': ['published', 'draft'] }) + + expect(result[0].value).toBe('published') + }) + }) +}) + +// ============================================================================ +// 2. SOURCE ADAPTERS +// ============================================================================ + +describe('createFiltersQueryAdapter', () => { + const adapter = createFiltersQueryAdapter() + + describe('decode', () => { + it('parses bracket notation from query object', () => { + const result = adapter.decode({ + 'filter[status]': 'published', + 'filter[price][gte]': '50' + }) + + expect(result).toEqual([ + { field: 'status', operator: 'eq', value: 'published' }, + { field: 'price', operator: 'gte', value: '50' } + ]) + }) + + it('returns empty array when no filters present', () => { + const result = adapter.decode({ sort: 'created_at:desc' }) + + expect(result).toEqual([]) + }) + }) + + describe('encode', () => { + it('converts normalized filters to bracket notation', () => { + const result = adapter.encode([ + { field: 'status', operator: 'eq', value: 'published' }, + { field: 'price', operator: 'gte', value: '50' } + ]) + + expect(result).toEqual({ + 'filter[status]': 'published', + 'filter[price][gte]': '50' + }) + }) + + it('uses null keyword for is_null operator', () => { + const result = adapter.encode([ + { field: 'deleted_at', operator: 'is_null' } + ]) + + expect(result).toEqual({ 'filter[deleted_at]': 'null' }) + }) + + it('uses not_null keyword for is_not_null operator', () => { + const result = adapter.encode([ + { field: 'deleted_at', operator: 'is_not_null' } + ]) + + expect(result).toEqual({ 'filter[deleted_at]': 'not_null' }) + }) + + it('joins array values with commas for in/nin', () => { + const result = adapter.encode([ + { field: 'status', operator: 'in', value: ['draft', 'published'] } + ]) + + expect(result).toEqual({ 'filter[status][in]': 'draft,published' }) + }) + }) + + describe('round-trip', () => { + const testCases: FiltersNormalized[] = [ + [{ field: 'status', operator: 'eq', value: 'published' }], + [{ field: 'price', operator: 'gte', value: '50' }], + [{ field: 'deleted_at', operator: 'is_null' }], + [ + { field: 'status', operator: 'eq', value: 'published' }, + { field: 'price', operator: 'gte', value: '50' } + ] + ] + + testCases.forEach((filters, i) => { + it(`preserves filters through encode/decode (case ${i + 1})`, () => { + const encoded = adapter.encode(filters) + const decoded = adapter.decode(encoded) + + expect(decoded).toEqual(filters) + }) + }) + }) +}) + +describe('createFiltersJsonAdapter', () => { + const adapter = createFiltersJsonAdapter() + + describe('decode', () => { + it('extracts filters array from { filters: [...] }', () => { + const result = adapter.decode({ + filters: [ + { field: 'status', operator: 'eq', value: 'published' } + ] + }) + + expect(result).toEqual([ + { field: 'status', operator: 'eq', value: 'published' } + ]) + }) + + it('defaults to empty array when filters missing', () => { + const result = adapter.decode({}) + + expect(result).toEqual([]) + }) + }) + + describe('encode', () => { + it('wraps normalized filters in { filters: [...] }', () => { + const result = adapter.encode([ + { field: 'status', operator: 'eq', value: 'published' } + ]) + + expect(result).toEqual({ + filters: [{ field: 'status', operator: 'eq', value: 'published' }] + }) + }) + }) +}) + +// ============================================================================ +// 3. VALIDATION (createFiltersSchema) +// ============================================================================ + +describe('createFiltersSchema', () => { + describe('DoS protection', () => { + it('rejects when filters exceed maxFilters limit', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY, + limits: { maxFilters: 3 } + }) + + const input = { + 'filter[status]': 'published', + 'filter[price][gte]': '50', + 'filter[price][lte]': '100', + 'filter[is_featured]': 'true' + } + + const result = schema.safeParse(input) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('maximum 3 allowed') + } + }) + + it('allows filters when equal to maxFilters limit', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY, + limits: { maxFilters: 3 } + }) + + const input = { + 'filter[status]': 'published', + 'filter[price][gte]': '50', + 'filter[price][lte]': '100' + } + + const result = schema.safeParse(input) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toHaveLength(3) + } + }) + + it('uses default maxFilters=20 when not configured', () => { + const schema = createFiltersSchema({ + source: 'query' + }) + + const twentyFilters = makeQueryFilters(20) + const twentyOneFilters = makeQueryFilters(21) + + const okResult = schema.safeParse(twentyFilters) + expect(okResult.success).toBe(true) + if (okResult.success) { + expect(okResult.data).toHaveLength(20) + } + + const badResult = schema.safeParse(twentyOneFilters) + expect(badResult.success).toBe(false) + if (!badResult.success) { + expect(badResult.error.issues[0].message).toContain('maximum 20 allowed') + } + }) + }) + + describe('field allowlist', () => { + it('rejects unknown fields when registry provided', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + const result = schema.safeParse({ 'filter[unknown_field]': 'value' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('not filterable') + } + }) + + it('allows any field when registry is empty', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: {} + }) + + const result = schema.safeParse({ 'filter[anything]': 'value' }) + + expect(result.success).toBe(true) + }) + + it('provides helpful error message listing allowed fields', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: { status: TEST_REGISTRY.status, price: TEST_REGISTRY.price } + }) + + const result = schema.safeParse({ 'filter[unknown]': 'value' }) + + expect(result.success).toBe(false) + if (!result.success) { + const msg = result.error.issues[0].message + expect(msg).toContain('status') + expect(msg).toContain('price') + } + }) + }) + + describe('defaults and limits interaction', () => { + const defaults = [ + makeFilter({ field: 'status', operator: 'eq', value: 'published' }), + makeFilter({ field: 'is_featured', operator: 'eq', value: true }) + ] + + it('applies maxFilters to defaults plus user filters', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY, + limits: { maxFilters: 3 }, + defaults + }) + + const okResult = schema.safeParse({ 'filter[price][gte]': '50' }) + expect(okResult.success).toBe(true) + if (okResult.success) { + expect(okResult.data).toHaveLength(3) + } + + const badResult = schema.safeParse({ + 'filter[price][gte]': '50', + 'filter[price][lte]': '100' + }) + expect(badResult.success).toBe(false) + if (!badResult.success) { + expect(badResult.error.issues[0].message).toContain('maximum 3 allowed') + } + }) + }) + + describe('operator allowlist', () => { + it('rejects operators not in field definition', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + // status only allows eq, ne, in, nin - not 'contains' + const result = schema.safeParse({ 'filter[status][contains]': 'pub' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('not allowed') + } + }) + + it('provides helpful error message listing allowed operators', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + const result = schema.safeParse({ 'filter[status][gt]': 'value' }) + + expect(result.success).toBe(false) + if (!result.success) { + const msg = result.error.issues[0].message + expect(msg).toContain('eq') + expect(msg).toContain('ne') + expect(msg).toContain('in') + } + }) + }) + + describe('type coercion - number', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + it('coerces string to number for numeric operators', () => { + const result = schema.safeParse({ 'filter[price][gte]': '50' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toBe(50) + expect(typeof result.data[0].value).toBe('number') + } + }) + + it('rejects NaN for numeric fields', () => { + const result = schema.safeParse({ 'filter[price][gte]': 'not-a-number' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('numeric') + } + }) + + it('handles negative numbers', () => { + const result = schema.safeParse({ 'filter[price][gte]': '-10' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toBe(-10) + } + }) + + it('handles decimal numbers', () => { + const result = schema.safeParse({ 'filter[price][gte]': '99.99' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toBe(99.99) + } + }) + }) + + describe('type coercion - boolean', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + it('coerces "true" to true', () => { + const result = schema.safeParse({ 'filter[is_featured][eq]': 'true' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toBe(true) + } + }) + + it('coerces "1" to true', () => { + const result = schema.safeParse({ 'filter[is_featured][eq]': '1' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toBe(true) + } + }) + + it('coerces "false" to false', () => { + const result = schema.safeParse({ 'filter[is_featured][eq]': 'false' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toBe(false) + } + }) + + it('coerces other values to false', () => { + const result = schema.safeParse({ 'filter[is_featured][eq]': 'anything' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toBe(false) + } + }) + }) + + describe('type coercion - date', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + it('coerces ISO string to ISO string (normalized)', () => { + const result = schema.safeParse({ 'filter[created_at][gte]': '2024-01-15T10:00:00Z' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toBe('2024-01-15T10:00:00.000Z') + } + }) + + it('rejects invalid date strings', () => { + const result = schema.safeParse({ 'filter[created_at][gte]': 'not-a-date' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('valid date') + } + }) + + it('handles date-only strings', () => { + const result = schema.safeParse({ 'filter[created_at][gte]': '2024-01-15' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + // Will parse as midnight UTC + expect(result.data[0].value).toContain('2024-01-15') + } + }) + }) + + describe('type coercion - enum', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + it('validates value against allowed values', () => { + const result = schema.safeParse({ 'filter[status][eq]': 'published' }) + + expect(result.success).toBe(true) + }) + + it('rejects values not in enum', () => { + const result = schema.safeParse({ 'filter[status][eq]': 'invalid_status' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('Invalid value') + } + }) + + it('provides helpful error with allowed values', () => { + const result = schema.safeParse({ 'filter[status][eq]': 'invalid' }) + + expect(result.success).toBe(false) + if (!result.success) { + const msg = result.error.issues[0].message + expect(msg).toContain('draft') + expect(msg).toContain('published') + expect(msg).toContain('archived') + } + }) + }) + + describe('type coercion - uuid', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + it('validates UUID format', () => { + const result = schema.safeParse({ + 'filter[user_id][eq]': '550e8400-e29b-41d4-a716-446655440000' + }) + + expect(result.success).toBe(true) + }) + + it('rejects malformed UUIDs', () => { + const result = schema.safeParse({ 'filter[user_id][eq]': 'not-a-uuid' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('UUID') + } + }) + }) + + describe('array operators', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + it('splits comma-separated values when operator in arrayOperators', () => { + const result = schema.safeParse({ 'filter[status][in]': 'draft,published' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toEqual(['draft', 'published']) + } + }) + + it('rejects empty arrays', () => { + const result = schema.safeParse({ 'filter[status][in]': '' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('at least 1') + } + }) + + it('trims whitespace from array values', () => { + const result = schema.safeParse({ 'filter[status][in]': ' draft , published ' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toEqual(['draft', 'published']) + } + }) + }) + + describe('enum validation with array operators', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + it('validates each value in in/nin against enum allowlist', () => { + // All values valid + const validResult = schema.safeParse({ 'filter[status][in]': 'draft,published' }) + expect(validResult.success).toBe(true) + + // One invalid value + const invalidResult = schema.safeParse({ 'filter[status][in]': 'published,invalid_status' }) + expect(invalidResult.success).toBe(false) + if (!invalidResult.success) { + expect(invalidResult.error.issues[0].message).toContain('Invalid values') + expect(invalidResult.error.issues[0].message).toContain('invalid_status') + } + }) + + it('rejects all invalid values in array', () => { + const result = schema.safeParse({ 'filter[status][in]': 'foo,bar,baz' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('foo') + expect(result.error.issues[0].message).toContain('bar') + expect(result.error.issues[0].message).toContain('baz') + } + }) + + it('provides allowed enum values in error message', () => { + const result = schema.safeParse({ 'filter[status][nin]': 'invalid' }) + + expect(result.success).toBe(false) + if (!result.success) { + const msg = result.error.issues[0].message + expect(msg).toContain('draft') + expect(msg).toContain('published') + expect(msg).toContain('archived') + } + }) + + it('coerces valid enum array values to strings', () => { + const result = schema.safeParse({ 'filter[status][in]': 'draft,published,archived' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toEqual(['draft', 'published', 'archived']) + // Verify all are strings + for (const v of result.data[0].value as string[]) { + expect(typeof v).toBe('string') + } + } + }) + }) + + describe('number validation with array operators', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: { + quantity: { + operators: ['in', 'nin'], + type: 'number', + arrayOperators: ['in', 'nin'] + } + } + }) + + it('coerces string values to numbers', () => { + const result = schema.safeParse({ 'filter[quantity][in]': '1,2,3' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toEqual([1, 2, 3]) + // Verify all are numbers + for (const v of result.data[0].value as number[]) { + expect(typeof v).toBe('number') + } + } + }) + + it('rejects non-numeric values in array', () => { + const result = schema.safeParse({ 'filter[quantity][in]': '1,two,3' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('numeric') + } + }) + + it('handles decimal numbers in array', () => { + const result = schema.safeParse({ 'filter[quantity][in]': '1.5,2.5,3.5' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toEqual([1.5, 2.5, 3.5]) + } + }) + }) + + describe('uuid validation with array operators', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: { + user_ids: { + operators: ['in', 'nin'], + type: 'uuid', + arrayOperators: ['in', 'nin'] + } + } + }) + + it('validates each UUID in array', () => { + const validUuids = '550e8400-e29b-41d4-a716-446655440000,6ba7b810-9dad-11d1-80b4-00c04fd430c8' + const result = schema.safeParse({ 'filter[user_ids][in]': validUuids }) + + expect(result.success).toBe(true) + }) + + it('rejects invalid UUIDs in array', () => { + const mixedUuids = '550e8400-e29b-41d4-a716-446655440000,not-a-uuid' + const result = schema.safeParse({ 'filter[user_ids][in]': mixedUuids }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('UUID') + expect(result.error.issues[0].message).toContain('not-a-uuid') + } + }) + }) + + describe('null operators', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY + }) + + it('is_null requires no value', () => { + const result = schema.safeParse({ 'filter[deleted_at]': 'null' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].operator).toBe('is_null') + expect(result.data[0].value).toBeUndefined() + } + }) + + it('is_not_null requires no value', () => { + const result = schema.safeParse({ 'filter[deleted_at]': 'not_null' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].operator).toBe('is_not_null') + expect(result.data[0].value).toBeUndefined() + } + }) + }) + + describe('defaults and merging', () => { + it('applies defaults when no user filters', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY, + defaults: [{ field: 'status', operator: 'eq', value: 'published' }] + }) + + const result = schema.safeParse({}) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data).toEqual([ + { field: 'status', operator: 'eq', value: 'published' } + ]) + } + }) + + it('merges defaults with user filters when mergeDefaults=true', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY, + defaults: [{ field: 'status', operator: 'eq', value: 'published' }], + mergeDefaults: true + }) + + const result = schema.safeParse({ 'filter[price][gte]': '50' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data).toHaveLength(2) + expect(result.data[0]).toEqual({ field: 'status', operator: 'eq', value: 'published' }) + expect(result.data[1].field).toBe('price') + } + }) + + it('skips defaults when user filters provided and mergeDefaults=false', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: TEST_REGISTRY, + defaults: [{ field: 'status', operator: 'eq', value: 'published' }], + mergeDefaults: false + }) + + const result = schema.safeParse({ 'filter[price][gte]': '50' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data).toHaveLength(1) + expect(result.data[0].field).toBe('price') + } + }) + }) + + describe('disabled mode', () => { + it('returns null when disabled=true', () => { + const schema = createFiltersSchema({ + source: 'query', + disabled: true + }) + + const result = schema.safeParse({ 'filter[status]': 'published' }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBeNull() + } + }) + }) +}) + +// ============================================================================ +// BETWEEN OPERATOR (BLOCKER - was missing) +// ============================================================================ + +describe('between operator', () => { + describe('number fields', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: { + price: { + operators: ['between'], + type: 'number' + } + } + }) + + it('coerces comma-separated string to [min, max] number array', () => { + const result = schema.safeParse({ 'filter[price][between]': '50,200' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].operator).toBe('between') + expect(result.data[0].value).toEqual([50, 200]) + } + }) + + it('rejects when not exactly 2 values (too few)', () => { + const result = schema.safeParse({ 'filter[price][between]': '50' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('exactly 2 values') + } + }) + + it('rejects when not exactly 2 values (too many)', () => { + const result = schema.safeParse({ 'filter[price][between]': '10,50,200' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('exactly 2 values') + } + }) + + it('rejects when min > max', () => { + const result = schema.safeParse({ 'filter[price][between]': '200,50' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('min <= max') + } + }) + + it('rejects non-numeric values', () => { + const result = schema.safeParse({ 'filter[price][between]': 'low,high' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('numeric') + } + }) + + it('handles decimal values', () => { + const result = schema.safeParse({ 'filter[price][between]': '9.99,99.99' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toEqual([9.99, 99.99]) + } + }) + + it('handles negative values', () => { + const result = schema.safeParse({ 'filter[price][between]': '-100,100' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toEqual([-100, 100]) + } + }) + + it('accepts equal min and max', () => { + const result = schema.safeParse({ 'filter[price][between]': '50,50' }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toEqual([50, 50]) + } + }) + }) + + describe('date fields', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: { + created_at: { + operators: ['between'], + type: 'date' + } + } + }) + + it('coerces comma-separated dates to ISO string array', () => { + const result = schema.safeParse({ + 'filter[created_at][between]': '2024-01-01,2024-12-31' + }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + const value = result.data[0].value as string[] + expect(value).toHaveLength(2) + expect(value[0]).toContain('2024-01-01') + expect(value[1]).toContain('2024-12-31') + } + }) + + it('rejects when start > end date', () => { + const result = schema.safeParse({ + 'filter[created_at][between]': '2024-12-31,2024-01-01' + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('start date <= end date') + } + }) + + it('rejects invalid date strings', () => { + const result = schema.safeParse({ + 'filter[created_at][between]': 'not-a-date,also-not-a-date' + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('valid dates') + } + }) + + it('handles ISO datetime strings', () => { + const result = schema.safeParse({ + 'filter[created_at][between]': '2024-01-15T00:00:00Z,2024-01-15T23:59:59Z' + }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + const value = result.data[0].value as string[] + expect(value[0]).toBe('2024-01-15T00:00:00.000Z') + expect(value[1]).toBe('2024-01-15T23:59:59.000Z') + } + }) + }) + + describe('unsupported types', () => { + it('rejects between on string type', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: { + title: { + operators: ['between'], + type: 'string' + } + } + }) + + const result = schema.safeParse({ 'filter[title][between]': 'a,z' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('not supported') + } + }) + + it('rejects between on boolean type', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: { + is_active: { + operators: ['between'], + type: 'boolean' + } + } + }) + + const result = schema.safeParse({ 'filter[is_active][between]': 'true,false' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('not supported') + } + }) + }) + + describe('JSON source', () => { + const schema = createFiltersSchema({ + source: 'json', + registry: { + price: { + operators: ['between'], + type: 'number' + } + } + }) + + it('accepts array value in JSON body', () => { + const result = schema.safeParse({ + filters: [ + { field: 'price', operator: 'between', value: [50, 200] } + ] + }) + + expect(result.success).toBe(true) + if (result.success && result.data !== null) { + expect(result.data[0].value).toEqual([50, 200]) + } + }) + }) +}) + +// ============================================================================ +// EDGE CASES +// ============================================================================ + +describe('edge cases', () => { + describe('empty and whitespace', () => { + const adapter = createFiltersQueryAdapter() + + it('returns empty array for empty input', () => { + const result = adapter.decode({}) + + expect(result).toEqual([]) + }) + + it('handles whitespace-only values', () => { + const result = adapter.decode({ 'filter[title]': ' ' }) + + expect(result[0].value).toBe(' ') + }) + }) + + describe('special characters', () => { + const adapter = createFiltersQueryAdapter() + + it('preserves special characters in values', () => { + const result = adapter.decode({ 'filter[title]': 'Hello & World!' }) + + expect(result[0].value).toBe('Hello & World!') + }) + + it('handles unicode in values', () => { + const result = adapter.decode({ 'filter[title]': '日本語テスト' }) + + expect(result[0].value).toBe('日本語テスト') + }) + + it('handles emoji in values', () => { + const result = adapter.decode({ 'filter[title]': '🚀 Launch' }) + + expect(result[0].value).toBe('🚀 Launch') + }) + }) + + describe('malformed input', () => { + const adapter = createFiltersQueryAdapter() + + it('ignores malformed filter keys', () => { + const result = adapter.decode({ + 'filter': 'value', // Missing brackets + 'filter[]': 'value', // Empty field + 'filter[field': 'value', // Unclosed bracket + 'filter[field][': 'value' // Unclosed operator bracket + }) + + expect(result).toEqual([]) + }) + }) +}) + +// ============================================================================ +// BASE FILTERS INTERACTION +// ============================================================================ + +describe('baseFilters interaction', () => { + /** + * baseFilters are system-level filters applied by handlers (e.g., realm scoping, + * soft-delete exclusion). They're injected at execution time, separate from user filters. + * + * Key behaviors to verify: + * 1. baseFilters don't count toward maxFilters limit + * 2. baseFilters are applied before user filters in execution + * 3. User cannot override baseFilters via query params + */ + + describe('maxFilters limit applies only to user filters', () => { + it('allows user filters up to maxFilters even with baseFilters present', () => { + // Scenario: maxFilters=3, user provides 3 filters + // baseFilters (realm_id, deleted_at) would be added at execution time + // User's 3 filters should be valid + + const schema = createFiltersSchema({ + source: 'query', + limits: { maxFilters: 3 } + }) + + const userFilters = { + 'filter[status][eq]': 'published', + 'filter[price][gte]': '50', + 'filter[category][in]': 'books,electronics' + } + + const result = schema.safeParse(userFilters) + expect(result.success).toBe(true) + + if (result.success) { + expect(result.data).toHaveLength(3) + } + }) + + it('rejects when user filters exceed maxFilters', () => { + const schema = createFiltersSchema({ + source: 'query', + limits: { maxFilters: 2 } + }) + + const userFilters = { + 'filter[status][eq]': 'published', + 'filter[price][gte]': '50', + 'filter[category][in]': 'books' // 3rd filter exceeds limit + } + + const result = schema.safeParse(userFilters) + expect(result.success).toBe(false) + + if (!result.success) { + expect(result.error.issues[0].message).toContain('maximum') + } + }) + }) + + describe('baseFilters field protection', () => { + /** + * Production pattern: handlers inject baseFilters for realm scoping. + * User should not be able to filter on realm_id themselves. + */ + + it('registry can exclude fields from user filtering', () => { + // Create registry that excludes realm_id from user filtering + const registry = new FieldRegistry() + registry.register('status', 'string', { filterable: true }) + registry.register('price', 'number', { filterable: true }) + registry.register('realm_id', 'string', { filterable: false }) // Not user-filterable + + const schema = createFiltersSchema({ + source: 'query', + registry + }) + + // User tries to filter on realm_id + const result = schema.safeParse({ + 'filter[realm_id][eq]': 'other-realm' + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('not filterable') + } + }) + + it('allows valid user filters while rejecting protected fields', () => { + const registry = new FieldRegistry() + registry.register('status', 'string', { filterable: true }) + registry.register('realm_id', 'string', { filterable: false }) + + const schema = createFiltersSchema({ + source: 'query', + registry + }) + + // Mixed: valid + invalid + const result = schema.safeParse({ + 'filter[status][eq]': 'published', + 'filter[realm_id][eq]': 'other-realm' + }) + + // Should reject because realm_id is not filterable + expect(result.success).toBe(false) + }) + }) + + describe('baseFilters merge semantics', () => { + /** + * When execution layer merges baseFilters with user filters, + * baseFilters should take precedence (applied first). + */ + + it('baseFilters and user filters can target same field if allowed', () => { + // User might filter status=published, while baseFilter might be deleted_at is null + // Both should be valid if field allows multiple filter instances + + const registry = new FieldRegistry() + registry.register('status', 'string', { filterable: true }) + registry.register('created_at', 'date', { filterable: true }) + + const schema = createFiltersSchema({ + source: 'query', + registry + }) + + // User provides date range filter + const result = schema.safeParse({ + 'filter[created_at][gte]': '2024-01-01', + 'filter[created_at][lt]': '2024-02-01' + }) + + expect(result.success).toBe(true) + if (result.success) { + // Both filters on same field should be preserved + expect(result.data).toHaveLength(2) + expect(result.data.every(f => f.field === 'created_at')).toBe(true) + } + }) + }) +}) + +// ============================================================================ +// PARAMETERIZED OPERATOR TESTS +// ============================================================================ + +describe('parameterized operator coverage', () => { + /** + * Systematic coverage of all operators with appropriate field types. + * Each operator is tested with valid input to ensure the full operator matrix works. + */ + + const operatorTestCases = [ + // Comparison operators - work on strings, numbers, dates + { operator: 'eq', field: 'status', type: 'string', value: 'active', expected: 'active' }, + { operator: 'ne', field: 'status', type: 'string', value: 'deleted', expected: 'deleted' }, + { operator: 'gt', field: 'price', type: 'number', value: '100', expected: 100 }, + { operator: 'gte', field: 'price', type: 'number', value: '50', expected: 50 }, + { operator: 'lt', field: 'price', type: 'number', value: '200', expected: 200 }, + { operator: 'lte', field: 'price', type: 'number', value: '150', expected: 150 }, + + // Set operators - work on arrays + { operator: 'in', field: 'status', type: 'string', value: 'a,b,c', expected: ['a', 'b', 'c'] }, + { operator: 'nin', field: 'status', type: 'string', value: 'x,y', expected: ['x', 'y'] }, + + // String operators + { operator: 'contains', field: 'title', type: 'string', value: 'test', expected: 'test' }, + { operator: 'icontains', field: 'title', type: 'string', value: 'TEST', expected: 'TEST' }, + { operator: 'startswith', field: 'title', type: 'string', value: 'pre', expected: 'pre' }, + { operator: 'endswith', field: 'title', type: 'string', value: 'fix', expected: 'fix' }, + + // Null operators - no value needed + { operator: 'is_null', field: 'deleted_at', type: 'date', value: 'null', expected: undefined }, + { operator: 'is_not_null', field: 'deleted_at', type: 'date', value: 'not_null', expected: undefined }, + ] + + // Create a registry that allows all operators on all test fields + const allOperatorsRegistry = { + status: { type: 'string', filterable: true, operators: ['eq', 'ne', 'in', 'nin', 'contains', 'icontains', 'startswith', 'endswith'] }, + price: { type: 'number', filterable: true, operators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'between'] }, + title: { type: 'string', filterable: true, operators: ['eq', 'ne', 'contains', 'icontains', 'startswith', 'endswith'] }, + deleted_at: { type: 'date', filterable: true, operators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'is_null', 'is_not_null', 'between'] }, + created_at: { type: 'date', filterable: true, operators: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'between'] }, + } + + operatorTestCases.forEach(({ operator, field, type, value, expected }) => { + it(`${operator} operator on ${type} field (${field})`, () => { + const schema = createFiltersSchema({ + source: 'query', + registry: allOperatorsRegistry + }) + + const input = operator === 'is_null' || operator === 'is_not_null' + ? { [`filter[${field}]`]: value } + : { [`filter[${field}][${operator}]`]: value } + + const result = schema.safeParse(input) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toHaveLength(1) + expect(result.data[0].field).toBe(field) + expect(result.data[0].operator).toBe(operator) + + if (expected !== undefined) { + expect(result.data[0].value).toEqual(expected) + } + } + }) + }) + + describe('between operator parameterized', () => { + const betweenCases = [ + { field: 'price', type: 'number', value: '10,100', expected: [10, 100] }, + { field: 'price', type: 'number', value: '0,1000', expected: [0, 1000] }, + { field: 'created_at', type: 'date', value: '2024-01-01,2024-12-31', expectedLength: 2 }, + ] + + betweenCases.forEach(({ field, type, value, expected, expectedLength }) => { + it(`between on ${type} field: ${value}`, () => { + const schema = createFiltersSchema({ + source: 'query', + registry: allOperatorsRegistry + }) + + const result = schema.safeParse({ + [`filter[${field}][between]`]: value + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data[0].operator).toBe('between') + if (expected) { + expect(result.data[0].value).toEqual(expected) + } + if (expectedLength) { + expect((result.data[0].value as unknown[]).length).toBe(expectedLength) + } + } + }) + }) + }) +}) + +// ============================================================================ +// FUZZ-LIKE EDGE CASE TESTS +// ============================================================================ + +describe('fuzz-like edge cases', () => { + const adapter = createFiltersQueryAdapter() + + describe('unicode and special strings', () => { + const unicodeCases = [ + { name: 'Chinese characters', value: '中文测试' }, + { name: 'Japanese hiragana', value: 'ひらがな' }, + { name: 'Korean hangul', value: '한글테스트' }, + { name: 'Arabic text', value: 'اختبار' }, + { name: 'Hebrew text', value: 'בדיקה' }, + { name: 'Thai script', value: 'ทดสอบ' }, + { name: 'Emoji sequence', value: '👨‍👩‍👧‍👦 Family' }, + { name: 'Mixed emoji', value: '🚀💻🔥' }, + { name: 'Zero-width joiner', value: 'a\u200Db' }, + { name: 'Combining characters', value: 'e\u0301' }, // é as e + combining acute + { name: 'Right-to-left mark', value: 'test\u200Fvalue' }, + ] + + unicodeCases.forEach(({ name, value }) => { + it(`handles ${name} in filter values`, () => { + const result = adapter.decode({ 'filter[title]': value }) + + expect(result).toHaveLength(1) + expect(result[0].value).toBe(value) + }) + }) + }) + + describe('potentially dangerous strings', () => { + const dangerousCases = [ + { name: 'SQL injection attempt', value: "'; DROP TABLE users; --" }, + { name: 'NoSQL injection', value: '{"$gt": ""}' }, + { name: 'XSS attempt', value: '' }, + { name: 'Path traversal', value: '../../../etc/passwd' }, + { name: 'Null bytes', value: 'test\x00value' }, + { name: 'Command injection', value: '$(rm -rf /)' }, + { name: 'LDAP injection', value: '*)(&' }, + { name: 'XML entity', value: '<xml>' }, + ] + + dangerousCases.forEach(({ name, value }) => { + it(`safely handles ${name}`, () => { + // These should be passed through as literal strings + // Actual sanitization happens at the execution/database layer + const result = adapter.decode({ 'filter[title]': value }) + + expect(result).toHaveLength(1) + expect(result[0].value).toBe(value) + expect(result[0].field).toBe('title') + }) + }) + }) + + describe('boundary string lengths', () => { + it('handles empty string value', () => { + const result = adapter.decode({ 'filter[title]': '' }) + + expect(result).toHaveLength(1) + expect(result[0].value).toBe('') + }) + + it('handles very long field names (255 chars)', () => { + const longField = 'a'.repeat(255) + const result = adapter.decode({ [`filter[${longField}]`]: 'value' }) + + expect(result).toHaveLength(1) + expect(result[0].field).toBe(longField) + }) + + it('handles very long values (10KB)', () => { + const longValue = 'x'.repeat(10 * 1024) + const result = adapter.decode({ 'filter[content]': longValue }) + + expect(result).toHaveLength(1) + expect(result[0].value).toBe(longValue) + }) + }) + + describe('numeric edge cases', () => { + const schema = createFiltersSchema({ + source: 'query', + registry: { + amount: { type: 'number', filterable: true, operators: ['eq', 'gt', 'lt', 'gte', 'lte', 'between'] } + } + }) + + const numericCases = [ + { name: 'zero', value: '0', expected: 0 }, + { name: 'negative zero', value: '-0', expected: 0 }, + { name: 'negative number', value: '-100', expected: -100 }, + { name: 'decimal', value: '3.14159', expected: 3.14159 }, + { name: 'scientific notation', value: '1e10', expected: 1e10 }, + { name: 'negative scientific', value: '-1.5e-3', expected: -1.5e-3 }, + { name: 'max safe integer', value: '9007199254740991', expected: 9007199254740991 }, + ] + + numericCases.forEach(({ name, value, expected }) => { + it(`coerces ${name}: "${value}"`, () => { + const result = schema.safeParse({ 'filter[amount][eq]': value }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data[0].value).toBe(expected) + } + }) + }) + + const invalidNumericCases = [ + { name: 'text', value: 'not-a-number' }, + { name: 'NaN string', value: 'NaN' }, + { name: 'Infinity', value: 'Infinity' }, + { name: 'mixed', value: '123abc' }, + ] + + invalidNumericCases.forEach(({ name, value }) => { + it(`rejects invalid number: ${name}`, () => { + const result = schema.safeParse({ 'filter[amount][eq]': value }) + + expect(result.success).toBe(false) + }) + }) + }) + + describe('field name edge cases', () => { + it('handles snake_case fields', () => { + const result = adapter.decode({ 'filter[created_at]': 'value' }) + expect(result[0].field).toBe('created_at') + }) + + it('handles camelCase fields', () => { + const result = adapter.decode({ 'filter[createdAt]': 'value' }) + expect(result[0].field).toBe('createdAt') + }) + + it('handles fields with numbers', () => { + const result = adapter.decode({ 'filter[field123]': 'value' }) + expect(result[0].field).toBe('field123') + }) + + it('handles fields starting with underscore', () => { + const result = adapter.decode({ 'filter[_internal]': 'value' }) + expect(result[0].field).toBe('_internal') + }) + }) + + describe('multiple filters stress test', () => { + it('handles maximum allowed filters (20)', () => { + const schema = createFiltersSchema({ + source: 'query', + limits: { maxFilters: 20 } + }) + + const input = Object.fromEntries( + Array.from({ length: 20 }, (_, i) => [`filter[field${i}][eq]`, `value${i}`]) + ) + + const result = schema.safeParse(input) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toHaveLength(20) + } + }) + }) +}) \ No newline at end of file diff --git a/packages/backend/src/query/index.ts b/supabase/functions/_shared/query/mod.ts similarity index 87% rename from packages/backend/src/query/index.ts rename to supabase/functions/_shared/query/mod.ts index c728107..c933829 100644 --- a/packages/backend/src/query/index.ts +++ b/supabase/functions/_shared/query/mod.ts @@ -1,4 +1,4 @@ -// utils/query/index.ts +// utils/query/mod.ts /** * Public surface for query utilities. * - Adapters and schema factories @@ -15,11 +15,11 @@ export * from './query.ts' /** * Example (Hono-like) handler usage * - * import { createEndpoindQuerySpec } from '@platform/backend/query/schemas.ts' + * import { createEndpoindQuerySpec } from './schemas.ts' * import { createValidator } from '#shared/middleware/validation.ts' // your existing middleware factory * - * import { executeListQuery } from '#shared/query/execution/supabase.ts' - * import { paginate, gone, badRequest } from '@platform/backend/response/index.ts' + * import { executeListQuery } from './execution/supabase.ts' + * import { paginate, gone, badRequest } from '#shared/utils/response/mod.ts' * * const QueryValidator = createValidator('query', createEndpoindQuerySpec({ * filters: { registry: { age: { operators: ['gte', 'lte'], valueType: 'number' } } }, diff --git a/packages/backend/src/query/pagination.ts b/supabase/functions/_shared/query/pagination.ts similarity index 62% rename from packages/backend/src/query/pagination.ts rename to supabase/functions/_shared/query/pagination.ts index 1cf07db..4bb2133 100644 --- a/packages/backend/src/query/pagination.ts +++ b/supabase/functions/_shared/query/pagination.ts @@ -10,30 +10,30 @@ * - Query/JSON/FormData source adapters */ -import type { CursorPaginationNormalized, OffsetPaginationNormalized, PaginationConfig, SortDirection } from './schemas.ts' +import type { CursorPaginationNormalized, OffsetPaginationNormalized, PaginationConfig, PaginationNormalized, SortDirection } from './schemas.ts' import type { Pagination, PaginationMetadata } from '../response/schemas.ts' import type { QuerySpec } from './schemas.ts' import { z } from 'zod' -import { createHmac } from 'node:crypto' -import { Buffer } from 'node:buffer' +import { decodeBase64Url, encodeBase64Url } from '@std/encoding/base64url' +import { createHmac } from "node:crypto" -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '../endpoint/schemas.ts' +import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '#shared/server/schemas.ts' import { PaginationNormalizedSchema, CursorDataSchema, type CursorData, } from './schemas.ts' -import { badRequest, ok, gone } from '../response/index.ts' +import { badRequest, ok, gone } from '../response/mod.ts' import { isSuccessResponse } from '../response/success.ts' // ============================================================================ -// CURSOR ENCODING/DECODING (existing, unchanged) +// CURSOR ENCODING/DECODING // ============================================================================ const EncodedCursorSchema = z.object({ data: CursorDataSchema, - signature: z.hex().length(64) + signature: z.hex(), }).strict() /** @@ -83,8 +83,7 @@ export function sortObject(obj: T): T { * - decode(): wire -> structured * - encode(): structured -> wire * - * Note: defaults/prefaults apply in forward (decode) direction, - * not in backward (encode) direction. Keep that in mind. + * This is a TRUE codec because encodeCursor() uses .encode() */ export const Base64UrlJsonCursorCodec = z.codec( z.string(), // wire type (Input) @@ -92,17 +91,21 @@ export const Base64UrlJsonCursorCodec = z.codec( { decode: (token) => { // base64url decode - const decoded = Buffer.from(token, 'base64url').toString('utf-8') - const parsed = JSON.parse(decoded) - return parsed; + const bytes = decodeBase64Url(token); + const json = new TextDecoder().decode(bytes); + return JSON.parse(json); }, encode: (obj) => { - const json = JSON.stringify(obj) - return Buffer.from(json).toString('base64url'); + const json = JSON.stringify(obj); + const bytes = new TextEncoder().encode(json); + return encodeBase64Url(bytes); }, } ); +/** + * Web-standard HMAC-SHA256 signature, hex-encoded. + */ export function hmacSha256Hex(secret: string, payload: unknown): string { // Important: canonicalize to avoid key-order signature drift. // If you don't have a stable stringify util, ensure 'data' is serialized consistently. @@ -124,8 +127,16 @@ export function decodeAndVerifyCursor( secret: string, ttlSeconds = 86_400, // 24h ) { + if (!token) { + return badRequest(token, "Invalid cursor token") + } + // 1) Decode token -> { data, signature } - const decoded = Base64UrlJsonCursorCodec.decode(token); + const { data: decoded, error } = Base64UrlJsonCursorCodec.safeDecode(token); + if (error) { + return badRequest(token, "Cannot decode cursor token") + } + const parsed = EncodedCursorSchema.safeParse(decoded); if (!parsed.success) { @@ -158,18 +169,19 @@ export function decodeAndVerifyCursor( /** * Create a new signed cursor token from CursorData. + * Uses Base64UrlJsonCursorCodec.encode() - this is why it stays a codec */ export function encodeCursor(data: CursorData, secret: string): string { - const envelope = Object.assign({}, { + const envelope = { data, signature: hmacSha256Hex(secret, data), - }); + }; return Base64UrlJsonCursorCodec.encode(envelope); } // ============================================================================ -// SOURCE ADAPTERS +// WIRE SCHEMAS // ============================================================================ // Query wire schema (raw incoming) @@ -182,10 +194,14 @@ export const PaginationQueryWire = BaseQuerySchema.extend({ }) // JSON wire schema (raw incoming) -export const PaginationJsonWire = BaseJsonSchema; // you already have this +export const PaginationJsonWire = BaseJsonSchema // Form wire schema (raw incoming) -export const PaginationFormWire = BaseFormSchema; +export const PaginationFormWire = BaseFormSchema + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ // Extract first value if array const getString = (val: unknown): string | undefined => { @@ -201,69 +217,105 @@ const pickForm = (raw: Record, key: string): string | undefined return val } +/** + * Decode pagination from query/form parameters + */ +function decodePagination(raw: Record, defaultLimit: number): PaginationNormalized { + const cursor = pickForm(raw, "cursor") + const offset = pickForm(raw, "offset") + const page = pickForm(raw, "page") + const limit = pickForm(raw, "limit") + const perPage = pickForm(raw, "per_page") + + // Cursor-based + if (cursor !== undefined) { + return { + type: 'cursor', + cursor: cursor || undefined, + limit: parseInt(limit ?? String(defaultLimit), 10) + } as CursorPaginationNormalized + } + + // Page-based + if (page !== undefined) { + const pageNum = parseInt(page, 10) + const limitNum = parseInt(perPage ?? limit ?? String(defaultLimit), 10) + return { + type: 'offset', + offset: (pageNum - 1) * limitNum, + limit: limitNum + } as OffsetPaginationNormalized + } + + // Offset-based + return { + type: 'offset', + offset: offset ? parseInt(offset, 10) : 0, + limit: parseInt(limit ?? String(defaultLimit), 10) + } as OffsetPaginationNormalized +} + +/** + * Encode pagination back to wire format + * Used for round-trip testing + */ +function encodePagination(norm: PaginationNormalized): Record { + if (norm.type === "cursor") { + const result: Record = { limit: String(norm.limit) } + if (norm.cursor) result.cursor = norm.cursor + return result + } + + return { + offset: String(norm.offset), + limit: String(norm.limit), + } +} + +// ============================================================================ +// SOURCE ADAPTERS (bidirectional codecs for testing) +// ============================================================================ /** - * Query parameter adapter (extends BaseQuerySchema) + * Query parameter adapter (codec for round-trip testing) * Supports: ?offset=0&limit=20, ?page=1&per_page=20, ?cursor=abc&limit=20 + * + * @example + * const adapter = createPaginationQueryAdapter() + * + * // Decode + * const normalized = adapter.decode({ offset: '0', limit: '20' }) + * // => { type: 'offset', offset: 0, limit: 20 } + * + * // Encode (for testing) + * const wire = adapter.encode(normalized) + * // => { offset: '0', limit: '20' } */ export function createPaginationQueryAdapter(defaultLimit: number = 20) { return z.codec( - PaginationQueryWire, // Input (wire) - PaginationNormalizedSchema, // Output (normalized) + PaginationQueryWire, + PaginationNormalizedSchema, { - decode: (raw) => { - const cursor = getString(raw.cursor) - const offset = getString(raw.offset) - const page = getString(raw.page) - const limit = getString(raw.limit) - const perPage = getString(raw.per_page) - - // Cursor-based - if (cursor !== undefined) { - return { - type: 'cursor', - cursor: cursor || undefined, - limit: parseInt(limit ?? String(defaultLimit), 10) - } as CursorPaginationNormalized - } - - // Page-based - if (page !== undefined) { - const pageNum = parseInt(page, 10) - const limitNum = parseInt(perPage ?? limit ?? String(defaultLimit), 10) - return { - type: 'offset', - offset: (pageNum - 1) * limitNum, - limit: limitNum - } as OffsetPaginationNormalized - } - - // Offset-based - return { - type: 'offset', - offset: parseInt(offset ?? '0', 10), - limit: parseInt(limit ?? String(defaultLimit), 10) - } as OffsetPaginationNormalized + decode: (raw): PaginationNormalized => { + return decodePagination(raw, defaultLimit) }, - // optional if you want to emit back out to wire shape: - encode: (norm) => { - if (norm.type === "cursor") { - return { - cursor: norm.cursor, - limit: String(norm.limit), - } as z.input; - } - return { - offset: String(norm.offset), - limit: String(norm.limit), - } as z.input; + encode: (norm): z.input => { + const normalized = PaginationNormalizedSchema.parse(norm) + return encodePagination(normalized) }, - }) + } + ) } /** - * JSON body adapter (uses BaseJsonSchema) + * JSON body adapter (codec for round-trip testing) * Expects: { pagination: { type: 'cursor', cursor: '...', limit: 20 } } + * + * @example + * const adapter = createPaginationJsonAdapter() + * const normalized = adapter.decode({ + * pagination: { type: 'offset', offset: 0, limit: 20 } + * }) */ export function createPaginationJsonAdapter(defaultLimit: number = 20) { const JsonEnvelope = z.object({ @@ -275,71 +327,44 @@ export function createPaginationJsonAdapter(defaultLimit: number = 20) { }) return z.codec( - BaseJsonSchema, // Input wire (your BaseJsonSchema) - PaginationNormalizedSchema, // Output normalized + PaginationJsonWire, + PaginationNormalizedSchema, { - decode: (raw) => JsonEnvelope.parse(raw).pagination, - encode: (norm) => ({ pagination: norm }) as z.input, + decode: (raw): PaginationNormalized => { + return JsonEnvelope.parse(raw).pagination + }, + encode: (norm): z.input => { + return { pagination: norm } + }, } - ); + ) } /** - * FormData adapter (uses makeBaseFormSchema) + * FormData adapter (codec for round-trip testing) * Supports same params as query adapter + * + * @example + * const adapter = createPaginationFormAdapter() + * const formData = new FormData() + * formData.append('offset', '0') + * formData.append('limit', '20') + * const normalized = adapter.decode(formData) */ export function createPaginationFormAdapter(defaultLimit: number = 20) { return z.codec( - PaginationFormWire, // Input wire - PaginationNormalizedSchema, // Output normalized + PaginationFormWire, + PaginationNormalizedSchema, { - decode: (raw) => { - const cursor = pickForm(raw, 'cursor') - const offset = pickForm(raw, 'offset') - const page = pickForm(raw, 'page') - const limit = pickForm(raw, 'limit') - const perPage = pickForm(raw, 'per_page') - - // Cursor-based - if (cursor !== undefined) { - return { - type: 'cursor', - cursor: cursor || undefined, - limit: parseInt(limit ?? String(defaultLimit), 10) - } as CursorPaginationNormalized - } - - // Page-based - if (page !== undefined) { - const pageNum = parseInt(page, 10) - const limitNum = parseInt(perPage ?? limit ?? String(defaultLimit), 10) - return { - type: 'offset', - offset: (pageNum - 1) * limitNum, - limit: limitNum - } as OffsetPaginationNormalized - } - - // Offset-based - return { - type: 'offset', - offset: parseInt(offset ?? '0', 10), - limit: parseInt(limit ?? String(defaultLimit), 10) - } as OffsetPaginationNormalized + decode: (raw): PaginationNormalized => { + return decodePagination(raw, defaultLimit) }, - encode: (norm) => { - if (norm.type === "cursor") - return { - cursor: norm.cursor, - limit: String(norm.limit) - } as z.infer; - - return { - offset: String(norm.offset), - limit: String(norm.limit) - } as z.infer; + encode: (norm): z.input => { + const normalized = PaginationNormalizedSchema.parse(norm) + return encodePagination(normalized) }, - }) + } + ) } // ============================================================================ @@ -348,7 +373,22 @@ export function createPaginationFormAdapter(defaultLimit: number = 20) { /** * Create endpoint-specific pagination schema with validation - * All validation happens in .superRefine() so middleware handles errors + * + * Note: This schema is unidirectional (decode only) because it adds + * non-reversible transformations (cursor decoding). Use the adapters + * directly for round-trip testing. + * + * @example + * // Minimal - all limits use defaults + * createPaginationSchema({ source: 'query' }) + * + * @example + * // Override specific limits + * createPaginationSchema({ + * source: 'query', + * limits: { maxLimit: 50 }, + * cursorSecret: 'my-secret' + * }) */ export function createPaginationSchema(config: { source: 'query' | 'json' | 'form' @@ -361,64 +401,84 @@ export function createPaginationSchema(config: { config.source === 'json' ? createPaginationJsonAdapter(defaultLimit) : createPaginationFormAdapter(defaultLimit) - return adapter.superRefine((pagination, ctx) => { - // Validate limits - if (pagination.limit < limits.minLimit) { - ctx.addIssue({ - code: "too_small", - minimum: limits.minLimit, - origin: "number", - path: ['limit'], - message: `Limit must be between ${limits.minLimit} and ${limits.maxLimit} (exclusive), got ${pagination.limit}`, - input: ctx.value - }) - } - - if (pagination.limit > limits.maxLimit) { - ctx.addIssue({ - code: "too_big", - maximum: limits.maxLimit, - origin: "number", - path: ['limit'], - message: `Limit must be between ${limits.minLimit} and ${limits.maxLimit} (exclusive), got ${pagination.limit}`, - input: ctx.value - }) - } + return adapter + // Step 1: Validate limits + .check((ctx) => { + const pagination = ctx.value + + // Validate limits + if (pagination.limit < (limits.minLimit ?? 1)) { + ctx.issues.push({ + code: "too_small", + minimum: limits.minLimit ?? 1, + origin: "number", + path: ['limit'], + message: `Limit must be between ${limits.minLimit ?? 1} and ${limits.maxLimit ?? 100} (exclusive), got ${pagination.limit}`, + input: pagination + }) + } - // Validate offset for DoS protection - if (pagination.type === 'offset' && pagination.offset > limits.maxOffset) { - ctx.addIssue({ - code: "too_big", - maximum: limits.maxOffset, - origin: "number", - path: ['offset'], - message: `Offset cannot exceed ${limits.maxOffset} (DoS protection), got ${pagination.offset}`, - input: ctx.value - }) - } + if (pagination.limit > (limits.maxLimit ?? 100)) { + ctx.issues.push({ + code: "too_big", + maximum: limits.maxLimit ?? 100, + origin: "number", + path: ['limit'], + message: `Limit must be between ${limits.minLimit ?? 1} and ${limits.maxLimit ?? 100} (exclusive), got ${pagination.limit}`, + input: pagination + }) + } - // Decode cursor if present (execution-time error, not validation) - if (pagination.type === 'cursor' && pagination.cursor && config.cursorSecret) { - const result = decodeAndVerifyCursor(pagination.cursor, config.cursorSecret, limits.cursorTTL); - if (isSuccessResponse(result)) { - // Mutation is safe here - Zod creates new object per parse - const [decoded] = result - pagination.decodedCursor = decoded.data - } else { - const [error] = result - ctx.addIssue({ - code: "custom", - path: ['decodedCursor'], - message: error.detail || 'Invalid or expired cursor', - input: ctx.value + // Validate offset for DoS protection + if (pagination.type === 'offset' && pagination.offset > (limits.maxOffset ?? 10000)) { + ctx.issues.push({ + code: "too_big", + maximum: limits.maxOffset ?? 10000, + origin: "number", + path: ['offset'], + message: `Offset cannot exceed ${limits.maxOffset ?? 10000} (DoS protection), got ${pagination.offset}`, + input: pagination }) } - } - }) + }) + + // Step 2: Decode cursor (runtime operation, in transform) + .transform((pagination, ctx): PaginationNormalized => { + // Skip if validation failed + if (ctx.issues.length > 0) return z.NEVER + + // Decode cursor if present + if (pagination.type === 'cursor' && pagination.cursor && config.cursorSecret) { + const result = decodeAndVerifyCursor( + pagination.cursor, + config.cursorSecret, + limits.cursorTTL ?? 86400 + ) + + if (isSuccessResponse(result)) { + const [decoded] = result + return { + ...pagination, + decodedCursor: decoded.data + } as CursorPaginationNormalized + } else { + const [error] = result + ctx.issues.push({ + code: "custom", + path: ['cursor'], + message: error.detail || 'Invalid or expired cursor', + input: pagination + }) + return z.NEVER + } + } + + return pagination + }) } // ============================================================================ -// RESPONSE GENERATION (existing, mostly unchanged) +// RESPONSE GENERATION // ============================================================================ /** @@ -439,14 +499,15 @@ export function cursorFromRow>(row: Row, cfg const tieValue = row[cfg.tiebreaker]; // Normalize known primitives; let CursorDataSchema enforce the rest. - const data = Object.assign({}, { + const data = { sortField: cfg.sortField, - sortValue: sortValue as unknown, + // sortValue can be string, number, or Date - schema handles coercion + sortValue: sortValue as string | number | Date, tiebreaker: cfg.tiebreaker, tiebreakerValue: tieValue as string | number, direction: cfg.direction, createdAt: new Date(), // mint time the cursor was issued - }); + }; // Validate to keep types honest return CursorDataSchema.parse(data); @@ -469,10 +530,10 @@ export function makeCursorTokens>(args: { }) { const count = args.items.length; if (count === 0) { - return Object.assign({}, { + return { next: undefined as string | undefined, prev: undefined as string | undefined - }); + }; } const head = args.items[0]; @@ -488,10 +549,10 @@ export function makeCursorTokens>(args: { ? cursorFromRow(head, { sortField: args.sortField, tiebreaker: args.tiebreaker, direction: args.direction === "asc" ? "desc" : "asc" }) : undefined; - return Object.assign({}, { + return { next: nextData ? encodeCursor(nextData, args.secret) : undefined, prev: prevData ? encodeCursor(prevData, args.secret) : undefined, - }); + }; } /** diff --git a/supabase/functions/_shared/query/pagination_test.ts b/supabase/functions/_shared/query/pagination_test.ts new file mode 100644 index 0000000..d3f9b59 --- /dev/null +++ b/supabase/functions/_shared/query/pagination_test.ts @@ -0,0 +1,2326 @@ +// utils/query/pagination_test.ts +/** + * Comprehensive unit tests for pagination utilities + * + * Test Structure (Effect-TS inspired - precise behavioral contracts): + * + * 1. CANONICALIZATION + * - sortObject() - deterministic key ordering for signatures + * + * 2. CRYPTOGRAPHY + * - hmacSha256Hex() - HMAC-SHA256 signature generation + * - Base64UrlJsonCursorCodec - wire encoding/decoding + * - encodeCursor() / decodeAndVerifyCursor() - signed cursor tokens + * + * 3. WIRE SCHEMAS (4 input forms) + * - Query params: ?offset=10&limit=20 or ?cursor=abc&limit=20 + * - JSON body: { pagination: { type: 'offset', offset: 10, limit: 20 } } + * - Form data: offset=10&limit=20 + * - Normalized output: { type: 'offset'|'cursor', ... } + * + * 4. ADAPTERS (decode/encode round-trips) + * - createPaginationQueryAdapter + * - createPaginationJsonAdapter + * - createPaginationFormAdapter + * + * 5. VALIDATION (createPaginationSchema) + * - Limit bounds (minLimit, maxLimit) + * - Offset bounds (maxOffset for DoS protection) + * - Cursor decoding with signature verification + * + * 6. RESPONSE GENERATION + * - cursorFromRow() - extract cursor data from DB rows + * - makeCursorTokens() - generate next/prev tokens + * - computeExpiresAt() - TTL calculation + * - buildPaginationMeta() - complete pagination metadata + */ + +import { describe, it } from '@std/testing/bdd' +import { expect } from '@std/expect' +import { Buffer } from 'node:buffer' + +import { + sortObject, + hmacSha256Hex, + encodeCursor, + decodeAndVerifyCursor, + createPaginationQueryAdapter, + createPaginationJsonAdapter, + createPaginationFormAdapter, + createPaginationSchema, + Base64UrlJsonCursorCodec, + cursorFromRow, + makeCursorTokens, + computeExpiresAt, + buildPaginationMeta, +} from './pagination.ts' + +import { isErrorResponse } from '../response/errors.ts' +import { isSuccessResponse } from '../response/success.ts' +import type { CursorData, QuerySpec } from './schemas.ts' + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +const TEST_SECRET = 'test-secret-key-for-hmac-signing-minimum-32-chars' + +/** + * Valid cursor data matching the actual CursorData schema shape + * (as produced by cursorFromRow) + */ +function makeValidCursorData(overrides: Partial = {}): CursorData { + return { + sortField: 'created_at', + sortValue: '2024-01-15T10:00:00Z', + tiebreaker: 'id', + tiebreakerValue: 'item-123', + direction: 'desc' as const, + createdAt: new Date(), + ...overrides, + } +} + +/** + * Sample database rows for testing cursor generation + */ +const sampleRows = [ + { id: 'row-1', created_at: '2024-01-15T10:00:00Z', title: 'First' }, + { id: 'row-2', created_at: '2024-01-15T11:00:00Z', title: 'Second' }, + { id: 'row-3', created_at: '2024-01-15T12:00:00Z', title: 'Third' }, +] + + +function makeQuerySpec(overrides: Partial = {}): QuerySpec { + return { + pagination: { type: 'offset', offset: 0, limit: 20 }, + filters: null, + sorts: null, + fields: null, + ...overrides + } +} + +function makeCursorQuerySpec(overrides: Partial = {}): QuerySpec { + return { + pagination: { type: 'cursor', limit: 20 }, + filters: null, + sorts: null, + fields: null, + ...overrides + } +} + +// ============================================================================ +// 1. CANONICALIZATION - sortObject() +// ============================================================================ + +describe('sortObject', () => { + describe('key ordering', () => { + it('sorts top-level keys alphabetically', () => { + const input = { z: 1, a: 2, m: 3 } + const result = sortObject(input) + + expect(Object.keys(result)).toEqual(['a', 'm', 'z']) + }) + + it('sorts nested object keys recursively', () => { + const input = { + outer: { z: 1, a: 2 }, + another: 'value', + } + const result = sortObject(input) + + expect(Object.keys(result)).toEqual(['another', 'outer']) + expect(Object.keys(result.outer)).toEqual(['a', 'z']) + }) + + it('sorts objects within arrays', () => { + const input = { + items: [ + { z: 1, a: 2 }, + { y: 3, b: 4 }, + ], + } + const result = sortObject(input) + + expect(Object.keys(result.items[0])).toEqual(['a', 'z']) + expect(Object.keys(result.items[1])).toEqual(['b', 'y']) + }) + + it('handles deeply nested structures', () => { + const input = { c: { b: { a: 1 } } } + const result = sortObject(input) + + expect(JSON.stringify(result)).toBe('{"c":{"b":{"a":1}}}') + }) + }) + + describe('type handling', () => { + it('converts Date to ISO string for consistent serialization', () => { + const date = new Date('2024-01-15T10:30:00Z') + const result = sortObject({ date }) + + expect(result.date).toBe('2024-01-15T10:30:00.000Z') + }) + + it('passes null through unchanged', () => { + expect(sortObject(null)).toBe(null) + }) + + it('passes undefined through unchanged', () => { + expect(sortObject(undefined)).toBe(undefined) + }) + + it('passes primitives through unchanged', () => { + expect(sortObject('string')).toBe('string') + expect(sortObject(42)).toBe(42) + expect(sortObject(true)).toBe(true) + expect(sortObject(0)).toBe(0) + expect(sortObject('')).toBe('') + }) + + it('preserves array element order (does not sort values)', () => { + const input = [3, 1, 2] + expect(sortObject(input)).toEqual([3, 1, 2]) + }) + + it('handles empty objects', () => { + expect(sortObject({})).toEqual({}) + }) + + it('handles empty arrays', () => { + expect(sortObject([])).toEqual([]) + }) + }) + + describe('signature stability', () => { + it('produces identical output for differently-ordered inputs', () => { + const a = { z: 1, a: 2, m: 3 } + const b = { a: 2, m: 3, z: 1 } + + expect(JSON.stringify(sortObject(a))).toBe(JSON.stringify(sortObject(b))) + }) + }) +}) + +// ============================================================================ +// 2. CRYPTOGRAPHY - HMAC and Cursor Tokens +// ============================================================================ + +describe('hmacSha256Hex', () => { + describe('determinism', () => { + it('generates identical signature for identical input', () => { + const payload = { id: '123', type: 'test' } + + const sig1 = hmacSha256Hex(TEST_SECRET, payload) + const sig2 = hmacSha256Hex(TEST_SECRET, payload) + + expect(sig1).toBe(sig2) + }) + + it('generates identical signature regardless of key order', () => { + const sig1 = hmacSha256Hex(TEST_SECRET, { b: 2, a: 1 }) + const sig2 = hmacSha256Hex(TEST_SECRET, { a: 1, b: 2 }) + + expect(sig1).toBe(sig2) + }) + }) + + describe('uniqueness', () => { + it('generates different signatures for different payloads', () => { + const sig1 = hmacSha256Hex(TEST_SECRET, { id: '123' }) + const sig2 = hmacSha256Hex(TEST_SECRET, { id: '456' }) + + expect(sig1).not.toBe(sig2) + }) + + it('generates different signatures for different secrets', () => { + const payload = { id: '123' } + + const sig1 = hmacSha256Hex('secret-1', payload) + const sig2 = hmacSha256Hex('secret-2', payload) + + expect(sig1).not.toBe(sig2) + }) + }) + + describe('format', () => { + it('returns 64-character lowercase hex string', () => { + const sig = hmacSha256Hex(TEST_SECRET, { test: true }) + + expect(sig).toHaveLength(64) + expect(/^[a-f0-9]{64}$/.test(sig)).toBe(true) + }) + }) +}) + +describe('Base64UrlJsonCursorCodec', () => { + describe('encode', () => { + it('produces URL-safe base64 string', () => { + const data = makeValidCursorData() + const envelope = { data, signature: 'a'.repeat(64) } + const encoded = Base64UrlJsonCursorCodec.encode(envelope) + + // Base64url uses only alphanumerics, underscore, and hyphen + expect(/^[A-Za-z0-9_-]+$/.test(encoded)).toBe(true) + }) + + it('does not include padding characters', () => { + const data = makeValidCursorData() + const envelope = { data, signature: 'b'.repeat(64) } + const encoded = Base64UrlJsonCursorCodec.encode(envelope) + + expect(encoded).not.toContain('=') + }) + }) + + describe('decode', () => { + it('successfully decodes valid token', () => { + const originalData = makeValidCursorData({ sortValue: 'decoded-value' }) + const original = { data: originalData, signature: 'c'.repeat(64) } + const token = Base64UrlJsonCursorCodec.encode(original) + const decoded = Base64UrlJsonCursorCodec.decode(token) + + expect(decoded.data.sortField).toBe(originalData.sortField) + expect(decoded.data.sortValue).toBe('decoded-value') + expect(decoded.signature).toBe(original.signature) + }) + + it('throws on invalid base64', () => { + expect(() => Base64UrlJsonCursorCodec.decode('not!valid@base64')) + .toThrow() + }) + + it('throws on invalid JSON', () => { + // Valid base64 but invalid JSON + const invalidJson = Buffer.from('not json').toString('base64url') + expect(() => Base64UrlJsonCursorCodec.decode(invalidJson)) + .toThrow() + }) + }) + + describe('round-trip', () => { + it('preserves data through encode/decode cycle', () => { + const originalData = makeValidCursorData({ + sortField: 'score', + sortValue: 42, + tiebreaker: 'uuid', + tiebreakerValue: 'abc-123', + direction: 'asc', + }) + const original = { + data: originalData, + signature: 'd'.repeat(64), + } + + const encoded = Base64UrlJsonCursorCodec.encode(original) + const decoded = Base64UrlJsonCursorCodec.decode(encoded) + + expect(decoded.data.sortField).toBe(originalData.sortField) + expect(decoded.data.sortValue).toBe(originalData.sortValue) + expect(decoded.data.tiebreaker).toBe(originalData.tiebreaker) + expect(decoded.data.tiebreakerValue).toBe(originalData.tiebreakerValue) + expect(decoded.data.direction).toBe(originalData.direction) + const decodedCreatedAt = (decoded.data as any).createdAt + expect(new Date(decodedCreatedAt).toISOString()).toBe(originalData.createdAt.toISOString()) + expect(decoded.signature).toBe(original.signature) + }) + }) +}) + +describe('encodeCursor', () => { + it('produces base64url encoded token', () => { + const data = makeValidCursorData() + const token = encodeCursor(data, TEST_SECRET) + + expect(/^[A-Za-z0-9_-]+$/.test(token)).toBe(true) + }) + + it('embeds correct signature', () => { + const data = makeValidCursorData() + const token = encodeCursor(data, TEST_SECRET) + const decoded = Base64UrlJsonCursorCodec.decode(token) + + // Signature should match HMAC of data + const expectedSig = hmacSha256Hex(TEST_SECRET, data) + expect(decoded.signature).toBe(expectedSig) + }) + + it('embeds cursor data', () => { + const data = makeValidCursorData({ sortField: 'score', sortValue: 99 }) + const token = encodeCursor(data, TEST_SECRET) + const decoded = Base64UrlJsonCursorCodec.decode(token) + + expect(decoded.data.sortField).toBe('score') + expect(decoded.data.sortValue).toBe(99) + }) +}) + +describe('decodeAndVerifyCursor', () => { + describe('valid cursors', () => { + it('returns success for valid unexpired cursor', () => { + const data = makeValidCursorData() + const token = encodeCursor(data, TEST_SECRET) + const result = decodeAndVerifyCursor(token, TEST_SECRET) + + expect(isSuccessResponse(result)).toBe(true) + expect(result[1]).toBe(200) + }) + + it('returns decoded cursor data on success', () => { + const data = makeValidCursorData({ sortValue: 'test-value' }) + const token = encodeCursor(data, TEST_SECRET) + const result = decodeAndVerifyCursor(token, TEST_SECRET) + + expect(isSuccessResponse(result)).toBe(true) + const [body] = result + expect((body as { data: typeof data }).data.sortValue).toBe('test-value') + }) + }) + + describe('malformed tokens', () => { + it('returns 400 for invalid base64', () => { + const result = decodeAndVerifyCursor('not!valid@base64', TEST_SECRET) + + expect(isErrorResponse(result)).toBe(true) + expect(result[1]).toBe(400) + }) + + it('returns 400 for valid base64 with invalid JSON', () => { + const invalidJson = Buffer.from('not json').toString('base64url') + const result = decodeAndVerifyCursor(invalidJson, TEST_SECRET) + + expect(isErrorResponse(result)).toBe(true) + expect(result[1]).toBe(400) + }) + + it('returns 400 for empty string', () => { + const result = decodeAndVerifyCursor('', TEST_SECRET) + + expect(isErrorResponse(result)).toBe(true) + expect(result[1]).toBe(400) + }) + }) + + describe('signature verification', () => { + it('returns 400 for tampered data', () => { + const data = makeValidCursorData() + const token = encodeCursor(data, TEST_SECRET) + + // Tamper with data while keeping original signature + const decoded = Base64UrlJsonCursorCodec.decode(token) + decoded.data.sortValue = 'tampered' + const tamperedToken = Base64UrlJsonCursorCodec.encode(decoded) + + const result = decodeAndVerifyCursor(tamperedToken, TEST_SECRET) + + expect(isErrorResponse(result)).toBe(true) + expect(result[1]).toBe(400) + expect((result[0] as { detail: string }).detail).toContain('signature') + }) + + it('returns 400 for wrong secret', () => { + const data = makeValidCursorData() + const token = encodeCursor(data, TEST_SECRET) + const result = decodeAndVerifyCursor(token, 'wrong-secret') + + expect(isErrorResponse(result)).toBe(true) + expect(result[1]).toBe(400) + }) + + it('returns 400 for truncated signature', () => { + const data = makeValidCursorData() + const token = encodeCursor(data, TEST_SECRET) + const decoded = Base64UrlJsonCursorCodec.decode(token) + decoded.signature = decoded.signature.slice(0, 32) // Truncate + const brokenToken = Base64UrlJsonCursorCodec.encode(decoded) + + const result = decodeAndVerifyCursor(brokenToken, TEST_SECRET) + + expect(isErrorResponse(result)).toBe(true) + expect(result[1]).toBe(400) + }) + }) + + describe('expiration (TTL)', () => { + it('returns 410 Gone for expired cursor (default 24h TTL)', () => { + const expiredData = makeValidCursorData({ + createdAt: new Date(Date.now() - 25 * 60 * 60 * 1000), // 25 hours ago + }) + const token = encodeCursor(expiredData, TEST_SECRET) + const result = decodeAndVerifyCursor(token, TEST_SECRET) + + expect(isErrorResponse(result)).toBe(true) + expect(result[1]).toBe(410) + }) + + it('accepts cursor within default TTL', () => { + const recentData = makeValidCursorData({ + createdAt: new Date(Date.now() - 23 * 60 * 60 * 1000), // 23 hours ago + }) + const token = encodeCursor(recentData, TEST_SECRET) + const result = decodeAndVerifyCursor(token, TEST_SECRET) + + expect(isSuccessResponse(result)).toBe(true) + }) + + it('accepts cursor within custom TTL', () => { + const data = makeValidCursorData({ + createdAt: new Date(Date.now() - 3600_000), // 1 hour ago + }) + const token = encodeCursor(data, TEST_SECRET) + const result = decodeAndVerifyCursor(token, TEST_SECRET, 7200) // 2h TTL + + expect(isSuccessResponse(result)).toBe(true) + }) + + it('rejects cursor exceeding custom TTL', () => { + const data = makeValidCursorData({ + createdAt: new Date(Date.now() - 3600_000), // 1 hour ago + }) + const token = encodeCursor(data, TEST_SECRET) + const result = decodeAndVerifyCursor(token, TEST_SECRET, 1800) // 30min TTL + + expect(isErrorResponse(result)).toBe(true) + expect(result[1]).toBe(410) + }) + + it('includes expiration delta in 410 response', () => { + const expiredData = makeValidCursorData({ + createdAt: new Date(Date.now() - 100_000_000), // ~27 hours ago + }) + const token = encodeCursor(expiredData, TEST_SECRET) + const result = decodeAndVerifyCursor(token, TEST_SECRET) + + expect(isErrorResponse(result)).toBe(true) + const [body] = result + expect((body as { deltaSeconds?: number }).deltaSeconds).toBeGreaterThan(0) + }) + }) + + describe('round-trip integrity', () => { + it('preserves all cursor data fields', () => { + const original = makeValidCursorData({ + sortField: 'score', + sortValue: 99.5, + tiebreaker: 'uuid', + tiebreakerValue: 'abc-123-def', + direction: 'asc', + }) + + const token = encodeCursor(original, TEST_SECRET) + const result = decodeAndVerifyCursor(token, TEST_SECRET) + + expect(isSuccessResponse(result)).toBe(true) + const decoded = (result[0] as { data: typeof original }).data + + expect(decoded.sortField).toBe('score') + expect(decoded.sortValue).toBe(99.5) + expect(decoded.tiebreaker).toBe('uuid') + expect(decoded.tiebreakerValue).toBe('abc-123-def') + expect(decoded.direction).toBe('asc') + }) + }) +}) + +// ============================================================================ +// 3. WIRE SCHEMAS - Query Adapter +// ============================================================================ + +describe('createPaginationQueryAdapter', () => { + const adapter = createPaginationQueryAdapter(20) + + describe('offset-based pagination', () => { + it('parses offset and limit from query params', () => { + const result = adapter.decode({ offset: '40', limit: '10' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(40) + expect(result.limit).toBe(10) + } + }) + + it('uses default limit when not provided', () => { + const result = adapter.decode({ offset: '0' }) + + expect(result.limit).toBe(20) + }) + + it('defaults to offset 0 when not provided', () => { + const result = adapter.decode({}) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(0) + } + }) + + it('parses zero offset', () => { + const result = adapter.decode({ offset: '0', limit: '10' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(0) + } + }) + }) + + describe('page-based pagination (converts to offset)', () => { + it('converts page 1 to offset 0', () => { + const result = adapter.decode({ page: '1', per_page: '10' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(0) + expect(result.limit).toBe(10) + } + }) + + it('converts page N to offset (N-1)*limit', () => { + const result = adapter.decode({ page: '3', per_page: '10' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(20) + } + }) + + it('prefers per_page over limit', () => { + const result = adapter.decode({ page: '2', per_page: '15', limit: '10' }) + + expect(result.limit).toBe(15) + }) + + it('falls back to limit when per_page not provided', () => { + const result = adapter.decode({ page: '2', limit: '25' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(25) + } + expect(result.limit).toBe(25) + }) + }) + + describe('cursor-based pagination', () => { + it('detects cursor mode when cursor param present', () => { + const result = adapter.decode({ cursor: 'abc123', limit: '15' }) + + expect(result.type).toBe('cursor') + if (result.type === 'cursor') { + expect(result.cursor).toBe('abc123') + expect(result.limit).toBe(15) + } + }) + + it('treats empty cursor as first page (cursor undefined)', () => { + const result = adapter.decode({ cursor: '', limit: '10' }) + + expect(result.type).toBe('cursor') + if (result.type === 'cursor') { + expect(result.cursor).toBeUndefined() + } + }) + + it('cursor takes precedence over offset params', () => { + const result = adapter.decode({ cursor: 'xyz', offset: '100', limit: '10' }) + + expect(result.type).toBe('cursor') + }) + }) + + describe('array value handling (ZStringOrStringArray)', () => { + it('takes first value when array provided', () => { + const result = adapter.decode({ + offset: ['10', '20'], + limit: ['5', '15'] + }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(10) + } + expect(result.limit).toBe(5) + }) + }) + + describe('custom default limit', () => { + it('respects custom default limit', () => { + const customAdapter = createPaginationQueryAdapter(50) + const result = customAdapter.decode({}) + + expect(result.limit).toBe(50) + }) + }) + + describe('encode (reverse transform)', () => { + it('encodes offset pagination back to query format', () => { + const normalized = { type: 'offset' as const, offset: 40, limit: 20 } + const wire = adapter.encode(normalized) + + expect(wire.offset).toBe('40') + expect(wire.limit).toBe('20') + }) + + it('encodes cursor pagination back to query format', () => { + const normalized = { type: 'cursor' as const, cursor: 'abc', limit: 15 } + const wire = adapter.encode(normalized) + + expect(wire.cursor).toBe('abc') + expect(wire.limit).toBe('15') + }) + }) + + describe('round-trip (decode → encode → decode)', () => { + it('preserves offset pagination through round-trip', () => { + const input = { offset: '50', limit: '25' } + const decoded = adapter.decode(input) + const encoded = adapter.encode(decoded) + const redecoded = adapter.decode(encoded) + + expect(redecoded).toEqual(decoded) + }) + + it('preserves cursor pagination through round-trip', () => { + const input = { cursor: 'token123', limit: '30' } + const decoded = adapter.decode(input) + const encoded = adapter.encode(decoded) + const redecoded = adapter.decode(encoded) + + expect(redecoded).toEqual(decoded) + }) + }) +}) + +// ============================================================================ +// 3. WIRE SCHEMAS - JSON Adapter +// ============================================================================ + +describe('createPaginationJsonAdapter', () => { + const adapter = createPaginationJsonAdapter(20) + + describe('offset-based pagination', () => { + it('extracts pagination from JSON body', () => { + const result = adapter.decode({ + pagination: { type: 'offset', offset: 100, limit: 25 }, + }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(100) + expect(result.limit).toBe(25) + } + }) + }) + + describe('cursor-based pagination', () => { + it('extracts cursor pagination from JSON body', () => { + const result = adapter.decode({ + pagination: { type: 'cursor', cursor: 'xyz789', limit: 10 }, + }) + + expect(result.type).toBe('cursor') + if (result.type === 'cursor') { + expect(result.cursor).toBe('xyz789') + expect(result.limit).toBe(10) + } + }) + }) + + describe('defaults', () => { + it('uses defaults when pagination not provided', () => { + const result = adapter.decode({}) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(0) + expect(result.limit).toBe(20) + } + }) + + it('uses custom default limit', () => { + const customAdapter = createPaginationJsonAdapter(50) + const result = customAdapter.decode({}) + + expect(result.limit).toBe(50) + }) + }) + + describe('encode', () => { + it('wraps normalized pagination in envelope', () => { + const normalized = { type: 'offset' as const, offset: 10, limit: 20 } + const wire = adapter.encode(normalized) + + expect(wire).toEqual({ pagination: normalized }) + }) + }) + + describe('round-trip', () => { + it('preserves data through encode/decode cycle', () => { + const input = { + pagination: { type: 'cursor' as const, cursor: 'abc', limit: 15 }, + } + const decoded = adapter.decode(input) + const encoded = adapter.encode(decoded) + const redecoded = adapter.decode(encoded) + + expect(redecoded).toEqual(decoded) + }) + }) +}) + +// ============================================================================ +// 3. WIRE SCHEMAS - Form Adapter +// ============================================================================ + +describe('createPaginationFormAdapter', () => { + const adapter = createPaginationFormAdapter(20) + + describe('offset-based pagination', () => { + it('parses form data like query params', () => { + const result = adapter.decode({ offset: '50', limit: '15' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(50) + expect(result.limit).toBe(15) + } + }) + }) + + describe('page-based pagination', () => { + it('converts page to offset', () => { + const result = adapter.decode({ page: '4', per_page: '20' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(60) + expect(result.limit).toBe(20) + } + }) + }) + + describe('cursor-based pagination', () => { + it('detects cursor mode', () => { + const result = adapter.decode({ cursor: 'form-cursor', limit: '10' }) + + expect(result.type).toBe('cursor') + if (result.type === 'cursor') { + expect(result.cursor).toBe('form-cursor') + } + }) + }) + + describe('defaults', () => { + it('uses defaults when no params provided', () => { + const result = adapter.decode({}) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(0) + expect(result.limit).toBe(20) + } + }) + }) + + describe('encode', () => { + it('encodes offset pagination to form format', () => { + const normalized = { type: 'offset' as const, offset: 30, limit: 10 } + const wire = adapter.encode(normalized) + + expect(wire.offset).toBe('30') + expect(wire.limit).toBe('10') + }) + + it('encodes cursor pagination to form format', () => { + const normalized = { type: 'cursor' as const, cursor: 'xyz', limit: 25 } + const wire = adapter.encode(normalized) + + expect(wire.cursor).toBe('xyz') + expect(wire.limit).toBe('25') + }) + }) +}) + +// ============================================================================ +// 4. VALIDATION - createPaginationSchema +// ============================================================================ + +describe('createPaginationSchema', () => { + describe('limit bounds validation', () => { + const schema = createPaginationSchema({ + source: 'query', + limits: { + minLimit: 5, + maxLimit: 100, + defaultLimit: 20, + maxOffset: 1_000_000, + cursorTTL: 86400, + }, + }) + + it('accepts limit within bounds', () => { + const result = schema.safeParse({ limit: '50' }) + expect(result.success).toBe(true) + }) + + it('rejects limit below minimum', () => { + const result = schema.safeParse({ limit: '2' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('limit') + expect(result.error.issues[0].message).toContain('5') + } + }) + + it('rejects limit above maximum', () => { + const result = schema.safeParse({ limit: '200' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('limit') + expect(result.error.issues[0].message).toContain('100') + } + }) + + it('accepts limit at minimum boundary', () => { + const result = schema.safeParse({ limit: '5' }) + expect(result.success).toBe(true) + }) + + it('accepts limit at maximum boundary', () => { + const result = schema.safeParse({ limit: '100' }) + expect(result.success).toBe(true) + }) + }) + + describe('offset bounds validation (DoS protection)', () => { + const schema = createPaginationSchema({ + source: 'query', + limits: { + minLimit: 1, + maxLimit: 100, + defaultLimit: 20, + maxOffset: 10000, + cursorTTL: 86400, + }, + }) + + it('accepts offset within bounds', () => { + const result = schema.safeParse({ offset: '5000' }) + expect(result.success).toBe(true) + }) + + it('rejects offset exceeding maximum', () => { + const result = schema.safeParse({ offset: '50000' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain('offset') + expect(result.error.issues[0].message).toContain('DoS') + } + }) + }) + + describe('cursor decoding integration', () => { + const schema = createPaginationSchema({ + source: 'query', + cursorSecret: TEST_SECRET, + limits: { + minLimit: 1, + maxLimit: 100, + defaultLimit: 20, + maxOffset: 1_000_000, + cursorTTL: 86400, + }, + }) + + it('decodes valid cursor and attaches data', () => { + const cursorData = makeValidCursorData() + const token = encodeCursor(cursorData, TEST_SECRET) + + const result = schema.safeParse({ cursor: token, limit: '10' }) + + expect(result.success).toBe(true) + if (result.success && result.data.type === 'cursor') { + expect(result.data.decodedCursor).toBeDefined() + expect(result.data.decodedCursor?.sortField).toBe('created_at') + } + }) + + it('rejects expired cursor', () => { + const expiredData = makeValidCursorData({ + createdAt: new Date(Date.now() - 100_000_000), + }) + const token = encodeCursor(expiredData, TEST_SECRET) + + const result = schema.safeParse({ cursor: token, limit: '10' }) + + expect(result.success).toBe(false) + }) + + it('rejects tampered cursor', () => { + const cursorData = makeValidCursorData() + const token = encodeCursor(cursorData, TEST_SECRET) + + // Tamper with token + const decoded = Base64UrlJsonCursorCodec.decode(token) + decoded.data.sortField = 'hacked' + const tampered = Base64UrlJsonCursorCodec.encode(decoded) + + const result = schema.safeParse({ cursor: tampered, limit: '10' }) + + expect(result.success).toBe(false) + }) + }) + + describe('source-specific parsing', () => { + it('parses query source', () => { + const schema = createPaginationSchema({ + source: 'query', + limits: { + minLimit: 1, + maxLimit: 100, + defaultLimit: 20, + maxOffset: 1_000_000, + cursorTTL: 86400, + }, + }) + const result = schema.safeParse({ offset: '10', limit: '20' }) + + expect(result.success).toBe(true) + }) + + it('parses json source', () => { + const schema = createPaginationSchema({ + source: 'json', + limits: { + minLimit: 1, + maxLimit: 100, + defaultLimit: 20, + maxOffset: 1_000_000, + cursorTTL: 86400, + }, + }) + const result = schema.safeParse({ + pagination: { type: 'offset', offset: 10, limit: 20 }, + }) + + expect(result.success).toBe(true) + }) + + it('parses form source', () => { + const schema = createPaginationSchema({ + source: 'form', + limits: { + minLimit: 1, + maxLimit: 100, + defaultLimit: 20, + maxOffset: 1_000_000, + cursorTTL: 86400, + }, + }) + const result = schema.safeParse({ offset: '10', limit: '20' }) + + expect(result.success).toBe(true) + }) + }) +}) + +// ============================================================================ +// 5. RESPONSE GENERATION +// ============================================================================ + +describe('cursorFromRow', () => { + it('extracts sort field value from row', () => { + const row = { id: 'abc', created_at: '2024-01-15T10:00:00Z', score: 99 } + const cursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + }) + + expect(cursor.sortField).toBe('created_at') + expect(cursor.sortValue).toBe('2024-01-15T10:00:00Z') + }) + + it('extracts tiebreaker value from row', () => { + const row = { id: 'xyz-123', created_at: '2024-01-15' } + const cursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'asc', + }) + + expect(cursor.tiebreaker).toBe('id') + expect(cursor.tiebreakerValue).toBe('xyz-123') + }) + + it('captures direction', () => { + const row = { id: '1', created_at: '2024-01-15' } + + const ascCursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'asc', + }) + const descCursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + }) + + expect(ascCursor.direction).toBe('asc') + expect(descCursor.direction).toBe('desc') + }) + + it('sets createdAt to current time', () => { + const before = Date.now() + const row = { id: '1', created_at: '2024-01-15' } + const cursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'asc', + }) + const after = Date.now() + + expect(cursor.createdAt.getTime()).toBeGreaterThanOrEqual(before) + expect(cursor.createdAt.getTime()).toBeLessThanOrEqual(after) + }) + + it('handles numeric sort values', () => { + const row = { id: '1', score: 99.5 } + const cursor = cursorFromRow(row, { + sortField: 'score', + tiebreaker: 'id', + direction: 'desc', + }) + + expect(cursor.sortValue).toBe(99.5) + }) +}) + +describe('makeCursorTokens', () => { + it('returns undefined tokens for empty items', () => { + const result = makeCursorTokens({ + items: [], + limit: 20, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'desc', + hasMoreForward: false, + }) + + expect(result.next).toBeUndefined() + expect(result.prev).toBeUndefined() + }) + + it('generates next token when hasMoreForward is true', () => { + const result = makeCursorTokens({ + items: sampleRows, + limit: 20, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'desc', + hasMoreForward: true, + }) + + expect(result.next).toBeDefined() + expect(typeof result.next).toBe('string') + }) + + it('does not generate next token when hasMoreForward is false', () => { + const result = makeCursorTokens({ + items: sampleRows, + limit: 20, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'desc', + hasMoreForward: false, + }) + + expect(result.next).toBeUndefined() + }) + + it('generates prev token when hasMoreBackward is true', () => { + const result = makeCursorTokens({ + items: sampleRows, + limit: 20, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'desc', + hasMoreForward: false, + hasMoreBackward: true, + }) + + expect(result.prev).toBeDefined() + }) + + it('next cursor points to last item in list', () => { + const result = makeCursorTokens({ + items: sampleRows, + limit: 20, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'desc', + hasMoreForward: true, + }) + + const decoded = decodeAndVerifyCursor(result.next!, TEST_SECRET) + expect(isSuccessResponse(decoded)).toBe(true) + + const cursor = (decoded[0] as { data: ReturnType }).data + expect(cursor.tiebreakerValue).toBe('row-3') // Last row + }) + + it('prev cursor inverts direction', () => { + const result = makeCursorTokens({ + items: sampleRows, + limit: 20, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'asc', + hasMoreForward: false, + hasMoreBackward: true, + }) + + const decoded = decodeAndVerifyCursor(result.prev!, TEST_SECRET) + expect(isSuccessResponse(decoded)).toBe(true) + + const cursor = (decoded[0] as { data: ReturnType }).data + expect(cursor.direction).toBe('desc') // Inverted from 'asc' + }) +}) + +describe('computeExpiresAt', () => { + it('returns undefined when hasCursor is false', () => { + const result = computeExpiresAt({ hasCursor: false, ttlSecs: 3600 }) + expect(result).toBeUndefined() + }) + + it('returns undefined when ttlSecs is undefined', () => { + const result = computeExpiresAt({ hasCursor: true }) + expect(result).toBeUndefined() + }) + + it('computes expiry from now + TTL', () => { + const now = new Date('2024-01-15T10:00:00Z') + const result = computeExpiresAt({ + now, + ttlSecs: 3600, + hasCursor: true + }) + + expect(result).toEqual(new Date('2024-01-15T11:00:00Z')) + }) + + it('uses current time when now not provided', () => { + const before = Date.now() + const result = computeExpiresAt({ ttlSecs: 3600, hasCursor: true }) + const after = Date.now() + + expect(result).toBeDefined() + expect(result!.getTime()).toBeGreaterThanOrEqual(before + 3600_000) + expect(result!.getTime()).toBeLessThanOrEqual(after + 3600_000) + }) +}) + +describe('buildPaginationMeta', () => { + const baseQuery = { + pagination: { type: 'offset' as const, offset: 0, limit: 20 }, + filters: null, + sorts: null, + fields: null, + } + + describe('limit+1 trimming', () => { + it('trims rows when more than limit', () => { + const rows = Array.from({ length: 21 }, (_, i) => ({ id: String(i) })) + const result = buildPaginationMeta({ + rows, + query: baseQuery, + }) + + expect(result.items).toHaveLength(20) + expect(result.pagination.hasMore).toBe(true) + }) + + it('keeps all rows when at or below limit', () => { + const rows = Array.from({ length: 15 }, (_, i) => ({ id: String(i) })) + const result = buildPaginationMeta({ + rows, + query: baseQuery, + }) + + expect(result.items).toHaveLength(15) + expect(result.pagination.hasMore).toBe(false) + }) + }) + + describe('offset pagination metadata', () => { + it('calculates nextOffset when hasMore', () => { + const rows = Array.from({ length: 21 }, (_, i) => ({ id: String(i) })) + const query = { + ...baseQuery, + pagination: { type: 'offset' as const, offset: 40, limit: 20 }, + } + + const result = buildPaginationMeta({ rows, query }) + + expect(result.pagination.nextOffset).toBe(60) + }) + + it('calculates prevOffset when offset > 0', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: String(i) })) + const query = { + ...baseQuery, + pagination: { type: 'offset' as const, offset: 40, limit: 20 }, + } + + const result = buildPaginationMeta({ rows, query }) + + expect(result.pagination.prevOffset).toBe(20) + }) + + it('prevOffset does not go below 0', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: String(i) })) + const query = { + ...baseQuery, + pagination: { type: 'offset' as const, offset: 10, limit: 20 }, + } + + const result = buildPaginationMeta({ rows, query }) + + expect(result.pagination.prevOffset).toBe(0) + }) + + it('includes total when provided', () => { + const rows = [{ id: '1' }] + const result = buildPaginationMeta({ + rows, + query: baseQuery, + total: 100, + }) + + expect(result.pagination.total).toBe(100) + }) + + it('includes approxTotal when provided', () => { + const rows = [{ id: '1' }] + const result = buildPaginationMeta({ + rows, + query: baseQuery, + approxTotal: 1000, + }) + + expect(result.pagination.approxTotal).toBe(1000) + }) + }) + + describe('cursor pagination metadata', () => { + it('generates cursor tokens when cursor mode', () => { + const rows = Array.from({ length: 21 }, (_, i) => ({ + id: String(i), + created_at: `2024-01-${15 + i}` + })) + const query = { + ...baseQuery, + pagination: { type: 'cursor' as const, limit: 20 }, + } + + const result = buildPaginationMeta({ + rows, + query, + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET, + }) + + expect(result.pagination.nextCursor).toBeDefined() + }) + + it('includes expiresAt when ttlSec provided', () => { + const rows = [{ id: '1', created_at: '2024-01-15' }] + const query = { + ...baseQuery, + pagination: { type: 'cursor' as const, limit: 20 }, + } + + const result = buildPaginationMeta({ + rows, + query, + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET, + ttlSec: 3600, + }) + + expect(result.pagination.expiresAt).toBeDefined() + }) + }) + + describe('common metadata', () => { + it('includes count of returned items', () => { + const rows = Array.from({ length: 15 }, (_, i) => ({ id: String(i) })) + const result = buildPaginationMeta({ rows, query: baseQuery }) + + expect(result.pagination.count).toBe(15) + }) + + it('includes limit', () => { + const result = buildPaginationMeta({ + rows: [], + query: baseQuery + }) + + expect(result.pagination.limit).toBe(20) + }) + }) +}) + +// ============================================================================ +// EDGE CASES +// ============================================================================ + +describe('edge cases', () => { + describe('numeric string parsing', () => { + const adapter = createPaginationQueryAdapter() + + it('handles leading zeros (decimal, not octal)', () => { + const result = adapter.decode({ offset: '007', limit: '010' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(7) + expect(result.limit).toBe(10) + } + }) + + it('handles negative offset (parsed as-is)', () => { + const result = adapter.decode({ offset: '-10', limit: '20' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(-10) + } + }) + + it('handles non-integer strings', () => { + const result = adapter.decode({ offset: '10.5', limit: '20' }) + + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(10) + } + }) + }) + + describe('large numbers', () => { + const adapter = createPaginationQueryAdapter() + + it('handles large offset values', () => { + const result = adapter.decode({ offset: '1000000', limit: '100' }) + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(1_000_000) + } + }) + + it('handles MAX_SAFE_INTEGER', () => { + const result = adapter.decode({ + offset: String(Number.MAX_SAFE_INTEGER), + limit: '100' + }) + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(Number.MAX_SAFE_INTEGER) + } + }) + }) + + describe('empty and whitespace', () => { + const adapter = createPaginationQueryAdapter(20) + + it('treats empty offset as 0', () => { + const result = adapter.decode({ offset: '', limit: '10' }) + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(result.offset).toBe(0) + } + }) + + it('handles whitespace-only values', () => { + const result = adapter.decode({ offset: ' ', limit: '10' }) + expect(result.type).toBe('offset') + if (result.type === 'offset') { + expect(Number.isNaN(result.offset)).toBe(true) + } + }) + }) + + describe('unicode in cursor tokens', () => { + it('handles unicode sort values', () => { + const data = makeValidCursorData({ sortValue: '日本語テスト' }) + const token = encodeCursor(data, TEST_SECRET) + const result = decodeAndVerifyCursor(token, TEST_SECRET) + + expect(isSuccessResponse(result)).toBe(true) + const decoded = (result[0] as { data: typeof data }).data + expect(decoded.sortValue).toBe('日本語テスト') + }) + }) +}) + + +describe('cursorFromRow - extended edge cases', () => { + describe('value type handling', () => { + it('handles Date object as sortValue', () => { + const date = new Date('2024-01-15T10:30:00Z') + const row = { id: 'abc', created_at: date } + + const cursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc' + }) + + // Date should be preserved or converted to ISO string + expect(cursor.sortValue).toBeDefined() + }) + + it('handles numeric sortValue', () => { + const row = { id: 'abc', score: 99.5, rank: 1 } + + const cursor = cursorFromRow(row, { + sortField: 'score', + tiebreaker: 'id', + direction: 'desc' + }) + + expect(cursor.sortValue).toBe(99.5) + expect(typeof cursor.sortValue).toBe('number') + }) + + it('handles integer sortValue', () => { + const row = { id: 'abc', rank: 42 } + + const cursor = cursorFromRow(row, { + sortField: 'rank', + tiebreaker: 'id', + direction: 'asc' + }) + + expect(cursor.sortValue).toBe(42) + }) + + it('handles numeric tiebreaker value', () => { + const row = { id: 12345, created_at: '2024-01-15' } + + const cursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc' + }) + + expect(cursor.tiebreakerValue).toBe(12345) + expect(typeof cursor.tiebreakerValue).toBe('number') + }) + + it('handles string tiebreaker value (UUID)', () => { + const row = { + id: '550e8400-e29b-41d4-a716-446655440000', + created_at: '2024-01-15' + } + + const cursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc' + }) + + expect(cursor.tiebreakerValue).toBe('550e8400-e29b-41d4-a716-446655440000') + }) + }) + + describe('null and undefined handling', () => { + it('handles null sortValue (schema should validate)', () => { + const row = { id: 'abc', created_at: null } + + // This may throw or produce null depending on schema + // Document actual behavior + try { + const cursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc' + }) + // If it succeeds, sortValue should be null + expect(cursor.sortValue).toBeNull() + } catch (e) { + // If schema rejects null, that's also valid behavior + expect(e).toBeDefined() + } + }) + + it('handles undefined sortField (missing column)', () => { + const row = { id: 'abc' } // no created_at + + try { + const cursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc' + }) + // If it succeeds, sortValue should be undefined + expect(cursor.sortValue).toBeUndefined() + } catch (e) { + // Schema may reject undefined + expect(e).toBeDefined() + } + }) + }) + + describe('field name edge cases', () => { + it('handles snake_case field names', () => { + const row = { item_id: 'abc', created_at: '2024-01-15' } + + const cursor = cursorFromRow(row, { + sortField: 'created_at', + tiebreaker: 'item_id', + direction: 'desc' + }) + + expect(cursor.tiebreaker).toBe('item_id') + expect(cursor.tiebreakerValue).toBe('abc') + }) + + it('handles camelCase field names', () => { + const row = { itemId: 'abc', createdAt: '2024-01-15' } + + const cursor = cursorFromRow(row, { + sortField: 'createdAt', + tiebreaker: 'itemId', + direction: 'desc' + }) + + expect(cursor.sortField).toBe('createdAt') + expect(cursor.tiebreaker).toBe('itemId') + }) + }) +}) + +// ============================================================================ +// 2. BUILD PAGINATION META - Extended Scenarios +// ============================================================================ + +describe('buildPaginationMeta - extended scenarios', () => { + describe('empty result handling', () => { + it('handles empty rows array with offset pagination', () => { + const result = buildPaginationMeta({ + rows: [], + query: makeQuerySpec({ pagination: { type: 'offset', offset: 0, limit: 20 } }) + }) + + expect(result.items).toEqual([]) + expect(result.pagination.count).toBe(0) + expect(result.pagination.hasMore).toBe(false) + }) + + it('handles empty rows with cursor pagination', () => { + const result = buildPaginationMeta({ + rows: [], + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET + }) + + expect(result.items).toEqual([]) + expect(result.pagination.count).toBe(0) + expect(result.pagination.hasMore).toBe(false) + expect(result.pagination.nextCursor).toBeUndefined() + }) + + it('handles empty rows on non-first page (offset > 0)', () => { + const result = buildPaginationMeta({ + rows: [], + query: makeQuerySpec({ pagination: { type: 'offset', offset: 100, limit: 20 } }) + }) + + expect(result.items).toEqual([]) + expect(result.pagination.count).toBe(0) + expect(result.pagination.hasMore).toBe(false) + // Should still have prevOffset since we're past first page + expect(result.pagination.prevOffset).toBe(80) + }) + }) + + describe('boundary conditions', () => { + it('exact limit (no probe row) - hasMore is false', () => { + const rows = Array.from({ length: 20 }, (_, i) => ({ + id: String(i), + created_at: `2024-01-${15 + i}` + })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec({ pagination: { type: 'offset', offset: 0, limit: 20 } }) + }) + + expect(result.items).toHaveLength(20) + expect(result.pagination.hasMore).toBe(false) + expect(result.pagination.count).toBe(20) + }) + + it('limit + 1 (has probe row) - hasMore is true, probe trimmed', () => { + const rows = Array.from({ length: 21 }, (_, i) => ({ + id: String(i), + created_at: `2024-01-${15 + i}` + })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec({ pagination: { type: 'offset', offset: 0, limit: 20 } }) + }) + + expect(result.items).toHaveLength(20) // Probe row trimmed + expect(result.pagination.hasMore).toBe(true) + expect(result.pagination.count).toBe(20) + }) + + it('less than limit - hasMore is false', () => { + const rows = Array.from({ length: 15 }, (_, i) => ({ + id: String(i), + created_at: `2024-01-${15 + i}` + })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec({ pagination: { type: 'offset', offset: 0, limit: 20 } }) + }) + + expect(result.items).toHaveLength(15) + expect(result.pagination.hasMore).toBe(false) + expect(result.pagination.count).toBe(15) + }) + + it('single row result', () => { + const rows = [{ id: '1', created_at: '2024-01-15' }] + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec({ pagination: { type: 'offset', offset: 0, limit: 20 } }) + }) + + expect(result.items).toHaveLength(1) + expect(result.pagination.hasMore).toBe(false) + expect(result.pagination.count).toBe(1) + }) + }) + + describe('offset calculations', () => { + it('calculates correct nextOffset', () => { + const rows = Array.from({ length: 21 }, (_, i) => ({ id: String(i) })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec({ pagination: { type: 'offset', offset: 40, limit: 20 } }) + }) + + expect(result.pagination.nextOffset).toBe(60) + }) + + it('calculates correct prevOffset when in middle', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: String(i) })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec({ pagination: { type: 'offset', offset: 60, limit: 20 } }) + }) + + expect(result.pagination.prevOffset).toBe(40) + }) + + it('prevOffset clamps to 0 when near start', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: String(i) })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec({ pagination: { type: 'offset', offset: 15, limit: 20 } }) + }) + + // 15 - 20 = -5, should clamp to 0 + expect(result.pagination.prevOffset).toBe(0) + }) + + it('no nextOffset when hasMore is false', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: String(i) })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec({ pagination: { type: 'offset', offset: 40, limit: 20 } }) + }) + + expect(result.pagination.hasMore).toBe(false) + expect(result.pagination.nextOffset).toBeUndefined() + }) + + it('no prevOffset when on first page', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: String(i) })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec({ pagination: { type: 'offset', offset: 0, limit: 20 } }) + }) + + expect(result.pagination.prevOffset).toBeUndefined() + }) + }) + + describe('cursor token generation', () => { + it('generates nextCursor when hasMore is true', () => { + const rows = Array.from({ length: 21 }, (_, i) => ({ + id: `id-${i}`, + created_at: `2024-01-${String(i + 1).padStart(2, '0')}` + })) + + const result = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET + }) + + expect(result.pagination.nextCursor).toBeDefined() + expect(typeof result.pagination.nextCursor).toBe('string') + }) + + it('nextCursor points to last returned item', () => { + const rows = Array.from({ length: 21 }, (_, i) => ({ + id: `id-${i}`, + created_at: `2024-01-${String(i + 1).padStart(2, '0')}` + })) + + const result = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET + }) + + // Decode the cursor to verify it points to the last returned item + const decoded = decodeAndVerifyCursor(result.pagination.nextCursor!, TEST_SECRET) + expect(isSuccessResponse(decoded)).toBe(true) + + if (isSuccessResponse(decoded)) { + // Last returned item is at index 19 (20th item, 0-indexed) + expect(decoded[0].data.tiebreakerValue).toBe('id-19') + } + }) + + it('no nextCursor when hasMore is false', () => { + const rows = Array.from({ length: 15 }, (_, i) => ({ + id: `id-${i}`, + created_at: `2024-01-${String(i + 1).padStart(2, '0')}` + })) + + const result = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET + }) + + expect(result.pagination.nextCursor).toBeUndefined() + }) + }) + + describe('TTL and expiration', () => { + it('calculates expiresAt when ttlSec provided', () => { + const rows = [{ id: '1', created_at: '2024-01-15' }] + const now = new Date() + + const result = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET, + ttlSec: 3600 // 1 hour + }) + + expect(result.pagination.expiresAt).toBeDefined() + + // Should be approximately 1 hour from now + const expiresAt = result.pagination.expiresAt! + const diffMs = expiresAt.getTime() - now.getTime() + const diffSec = diffMs / 1000 + + // Allow 5 second tolerance for test execution time + expect(diffSec).toBeGreaterThan(3595) + expect(diffSec).toBeLessThan(3605) + }) + + it('no expiresAt when ttlSec not provided', () => { + const rows = [{ id: '1', created_at: '2024-01-15' }] + + const result = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET + // No ttlSec + }) + + expect(result.pagination.expiresAt).toBeUndefined() + }) + }) + + describe('total count handling', () => { + it('includes exact total when provided', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: String(i) })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec(), + total: 150 + }) + + expect(result.pagination.total).toBe(150) + }) + + it('includes approximate total when provided', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: String(i) })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec(), + approxTotal: 10000 + }) + + expect(result.pagination.approxTotal).toBe(10000) + }) + + it('prefers total over approxTotal when both provided', () => { + const rows = Array.from({ length: 10 }, (_, i) => ({ id: String(i) })) + + const result = buildPaginationMeta({ + rows, + query: makeQuerySpec(), + total: 150, + approxTotal: 10000 + }) + + expect(result.pagination.total).toBe(150) + expect(result.pagination.approxTotal).toBe(10000) + }) + }) +}) + +// ============================================================================ +// 3. CURSOR CONTINUATION FLOW +// ============================================================================ + +describe('cursor continuation flow', () => { + const generateRows = (start: number, count: number) => + Array.from({ length: count }, (_, i) => ({ + id: `id-${start + i}`, + created_at: `2024-01-${String(start + i + 1).padStart(2, '0')}T10:00:00Z`, + title: `Item ${start + i}` + })) + + describe('first page to next page', () => { + it('first page generates valid nextCursor', () => { + // Simulate first page request: 21 rows (20 + probe) + const rows = generateRows(0, 21) + + const result = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec({ pagination: { type: 'cursor', limit: 20 } }), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET + }) + + expect(result.items).toHaveLength(20) + expect(result.pagination.hasMore).toBe(true) + expect(result.pagination.nextCursor).toBeDefined() + + // Verify cursor can be decoded + const decoded = decodeAndVerifyCursor(result.pagination.nextCursor!, TEST_SECRET) + expect(isSuccessResponse(decoded)).toBe(true) + }) + + it('cursor from first page can be used for second page', () => { + // First page + const firstPageRows = generateRows(0, 21) + const firstPage = buildPaginationMeta({ + rows: firstPageRows, + query: makeCursorQuerySpec({ pagination: { type: 'cursor', limit: 20 } }), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET + }) + + // Decode the cursor + const decoded = decodeAndVerifyCursor(firstPage.pagination.nextCursor!, TEST_SECRET) + expect(isSuccessResponse(decoded)).toBe(true) + + if (isSuccessResponse(decoded)) { + const cursor = decoded[0] + + // Cursor should contain correct sort info + expect(cursor.data.sortField).toBe('created_at') + expect(cursor.data.tiebreaker).toBe('id') + expect(cursor.data.direction).toBe('desc') + + // Cursor should point to last item of first page (id-19) + expect(cursor.data.tiebreakerValue).toBe('id-19') + } + }) + }) + + describe('cursor direction consistency', () => { + it('maintains direction through encode/decode cycle', () => { + const rows = generateRows(0, 21) + + // Test with ascending direction + const ascResult = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'asc', + secret: TEST_SECRET + }) + + const ascDecoded = decodeAndVerifyCursor(ascResult.pagination.nextCursor!, TEST_SECRET) + if (isSuccessResponse(ascDecoded)) { + expect(ascDecoded[0].data.direction).toBe('asc') + } + + // Test with descending direction + const descResult = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET + }) + + const descDecoded = decodeAndVerifyCursor(descResult.pagination.nextCursor!, TEST_SECRET) + if (isSuccessResponse(descDecoded)) { + expect(descDecoded[0].data.direction).toBe('desc') + } + }) + }) + + describe('cursor expiration', () => { + it('cursor without TTL does not expire', () => { + const rows = generateRows(0, 21) + + const result = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET + // No ttlSec + }) + + // Cursor should be valid + const decoded = decodeAndVerifyCursor(result.pagination.nextCursor!, TEST_SECRET) + expect(isSuccessResponse(decoded)).toBe(true) + }) + + it('cursor with TTL includes expiration info', () => { + const rows = generateRows(0, 21) + + const result = buildPaginationMeta({ + rows, + query: makeCursorQuerySpec(), + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET, + ttlSec: 3600 + }) + + // Response should include expiration time + expect(result.pagination.expiresAt).toBeDefined() + }) + }) +}) + +// ============================================================================ +// 4. COMPUTE EXPIRES AT +// ============================================================================ + +describe('computeExpiresAt', () => { + it('returns undefined when no cursor and no TTL', () => { + const result = computeExpiresAt({}) + + expect(result).toBeUndefined() + }) + + it('returns undefined when hasCursor false', () => { + const result = computeExpiresAt({ + hasCursor: false, + ttlSecs: 3600 + }) + + expect(result).toBeUndefined() + }) + + it('calculates expiry from TTL when cursor exists', () => { + const now = new Date('2024-01-15T10:00:00Z') + + const result = computeExpiresAt({ + now, + hasCursor: true, + ttlSecs: 3600 // 1 hour + }) + + expect(result).toBeDefined() + expect(result!.toISOString()).toBe('2024-01-15T11:00:00.000Z') + }) + + it('uses current time when now not provided', () => { + const before = new Date() + + const result = computeExpiresAt({ + hasCursor: true, + ttlSecs: 3600 + }) + + const after = new Date() + + expect(result).toBeDefined() + // Result should be ~1 hour from now + expect(result!.getTime()).toBeGreaterThanOrEqual(before.getTime() + 3600000 - 1000) + expect(result!.getTime()).toBeLessThanOrEqual(after.getTime() + 3600000 + 1000) + }) + + it('handles zero TTL', () => { + const now = new Date('2024-01-15T10:00:00Z') + + const result = computeExpiresAt({ + now, + hasCursor: true, + ttlSecs: 0 + }) + + expect(result).toBeDefined() + expect(result!.toISOString()).toBe('2024-01-15T10:00:00.000Z') + }) + + it('handles large TTL values', () => { + const now = new Date('2024-01-15T10:00:00Z') + + const result = computeExpiresAt({ + now, + hasCursor: true, + ttlSecs: 86400 * 365 // 1 year in seconds + }) + + expect(result).toBeDefined() + // Should be ~1 year later + expect(result!.getFullYear()).toBe(2025) + }) +}) + +// ============================================================================ +// 5. MAKE CURSOR TOKENS - Extended Tests +// ============================================================================ + +describe('makeCursorTokens - extended', () => { + const sampleRows = [ + { id: 'row-1', created_at: '2024-01-15T10:00:00Z' }, + { id: 'row-2', created_at: '2024-01-15T11:00:00Z' }, + { id: 'row-3', created_at: '2024-01-15T12:00:00Z' }, + ] + + describe('bidirectional pagination', () => { + it('generates both next and prev when both directions have more', () => { + const result = makeCursorTokens({ + items: sampleRows, + limit: 3, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'desc', + hasMoreForward: true, + hasMoreBackward: true + }) + + expect(result.next).toBeDefined() + expect(result.prev).toBeDefined() + }) + + it('prev cursor has inverted direction', () => { + const result = makeCursorTokens({ + items: sampleRows, + limit: 3, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'desc', + hasMoreForward: false, + hasMoreBackward: true + }) + + const decoded = decodeAndVerifyCursor(result.prev!, TEST_SECRET) + + if (isSuccessResponse(decoded)) { + // When main direction is 'desc', prev cursor should be 'asc' + expect(decoded[0].data.direction).toBe('asc') + } + }) + + it('next cursor points to tail, prev cursor points to head', () => { + const result = makeCursorTokens({ + items: sampleRows, + limit: 3, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'desc', + hasMoreForward: true, + hasMoreBackward: true + }) + + const nextDecoded = decodeAndVerifyCursor(result.next!, TEST_SECRET) + const prevDecoded = decodeAndVerifyCursor(result.prev!, TEST_SECRET) + + if (isSuccessResponse(nextDecoded)) { + // Next cursor should point to last item (row-3) + expect(nextDecoded[0].data.tiebreakerValue).toBe('row-3') + } + + if (isSuccessResponse(prevDecoded)) { + // Prev cursor should point to first item (row-1) + expect(prevDecoded[0].data.tiebreakerValue).toBe('row-1') + } + }) + }) + + describe('single item edge case', () => { + it('handles single item with hasMoreForward', () => { + const singleRow = [{ id: 'single', created_at: '2024-01-15' }] + + const result = makeCursorTokens({ + items: singleRow, + limit: 1, + sortField: 'created_at', + tiebreaker: 'id', + secret: TEST_SECRET, + direction: 'desc', + hasMoreForward: true + }) + + expect(result.next).toBeDefined() + + const decoded = decodeAndVerifyCursor(result.next!, TEST_SECRET) + if (isSuccessResponse(decoded)) { + expect(decoded[0].data.tiebreakerValue).toBe('single') + } + }) + }) +}) + +// ============================================================================ +// 6. CROSS-COMPONENT INTERACTION +// ============================================================================ + +describe('cross-component interaction', () => { + describe('query spec with all components', () => { + it('buildPaginationMeta works with full query spec', () => { + const rows = Array.from({ length: 21 }, (_, i) => ({ + id: `id-${i}`, + created_at: `2024-01-${String(i + 1).padStart(2, '0')}`, + status: 'published', + price: 100 + i + })) + + const fullQuerySpec: QuerySpec = { + pagination: { type: 'cursor', limit: 20 }, + filters: [ + { field: 'status', operator: 'eq', value: 'published' }, + { field: 'price', operator: 'gte', value: 100 } + ], + sorts: [ + { field: 'created_at', direction: 'desc', tiebreaker: false }, + { field: 'id', direction: 'desc', tiebreaker: true } + ], + fields: { type: 'simple', fields: ['id', 'created_at', 'status'] } + } + + const result = buildPaginationMeta({ + rows, + query: fullQuerySpec, + sortField: 'created_at', + tiebreaker: 'id', + direction: 'desc', + secret: TEST_SECRET, + ttlSec: 3600, + total: 150 + }) + + // All pagination fields should be present + expect(result.items).toHaveLength(20) + expect(result.pagination.hasMore).toBe(true) + expect(result.pagination.nextCursor).toBeDefined() + expect(result.pagination.expiresAt).toBeDefined() + expect(result.pagination.total).toBe(150) + expect(result.pagination.count).toBe(20) + expect(result.pagination.limit).toBe(20) + }) + }) + + describe('query metadata passthrough', () => { + it('includes query metadata in response when available', () => { + const rows = [{ id: '1', created_at: '2024-01-15' }] + + const querySpec: QuerySpec = { + pagination: { type: 'offset', offset: 0, limit: 20 }, + filters: [{ field: 'status', operator: 'eq', value: 'active' }], + sorts: [{ field: 'created_at', direction: 'desc', tiebreaker: false }], + fields: null + } + + const result = buildPaginationMeta({ + rows, + query: querySpec + }) + + // Verify the result includes the expected fields + expect(result.pagination).toBeDefined() + expect(result.items).toBeDefined() + }) + }) +}) diff --git a/packages/backend/src/query/query.ts b/supabase/functions/_shared/query/query.ts similarity index 75% rename from packages/backend/src/query/query.ts rename to supabase/functions/_shared/query/query.ts index 4c42c77..75b50ed 100644 --- a/packages/backend/src/query/query.ts +++ b/supabase/functions/_shared/query/query.ts @@ -18,7 +18,6 @@ import { EndpointQueryConfigSchema, type EndpointQueryConfig, - type QuerySpec } from './schemas.ts' import { createPaginationSchema } from './pagination.ts' @@ -26,7 +25,8 @@ import { createFiltersSchema } from './filtering.ts' import { createSortsSchema } from './sorting.ts' import { createFieldsSchema } from './fields.ts' -import { BaseFormSchema, BaseJsonSchema, BaseQuerySchema } from '../endpoint/schemas.ts' +import { BaseFormSchema, BaseJsonSchema, BaseQuerySchema } from '#shared/server/schemas.ts' +import z from 'zod' // Re-export QuerySpec type export { QuerySpecSchema, type QuerySpec } from './schemas.ts' @@ -77,51 +77,32 @@ export function createQuerySpec( configInput?: EndpointQueryConfig ) { const config = EndpointQueryConfigSchema.parse(configInput) - - console.log({ - config - }) - - // Pagination is always enabled (required for collection endpoints) - const paginationSchema = createPaginationSchema({ - source, - ...config.pagination + + const BaseInputSchema = source === 'query' ? BaseQuerySchema + : source === 'json' ? BaseJsonSchema + : BaseFormSchema + + // Avoid shadowing the imported QuerySpecSchema + const CompositeQuerySpecSchema = z.object({ + pagination: createPaginationSchema({ source, ...config.pagination }), + filters: config.filters.disabled + ? z.null() + : createFiltersSchema({ source, ...config.filters }).nullable(), + sorts: config.sorts.disabled + ? z.null() + : createSortsSchema({ source, ...config.sorts }).nullable(), + fields: config.fields.disabled + ? z.null() + : createFieldsSchema({ source, ...config.fields }).nullable(), }) - const filtersSchema = !config?.filters.disabled ? createFiltersSchema({ - source, - ...config.filters - }) : null - - const sortsSchema = !config?.sorts.disabled ? createSortsSchema({ - source, - ...config.sorts - }) : null - - const fieldsSchema = !config?.fields.disabled ? createFieldsSchema({ - source, - ...config.fields - }) : null - - const BaseInputSchema = - source === 'query' ? BaseQuerySchema : - source === 'json' ? BaseJsonSchema : - BaseFormSchema - - return BaseInputSchema.transform((raw): QuerySpec => { - // Parse each component (disabled ones return null) - const pagination = paginationSchema.parse(raw) - const filters = filtersSchema ? filtersSchema.parse(raw) : null - const sorts = sortsSchema ? sortsSchema.parse(raw) : null - const fields = fieldsSchema ? fieldsSchema.parse(raw) : null - - return { - pagination, - filters, - sorts, - fields, - } - }) + // Use transform to reshape input, then pipe to validation + return BaseInputSchema.transform((raw) => ({ + pagination: raw, + filters: config.filters.disabled ? null : raw, + sorts: config.sorts.disabled ? null : raw, + fields: config.fields.disabled ? null : raw, + })).pipe(CompositeQuerySpecSchema) } // ============================================================================ diff --git a/supabase/functions/_shared/query/query_test.ts b/supabase/functions/_shared/query/query_test.ts new file mode 100644 index 0000000..9d3376e --- /dev/null +++ b/supabase/functions/_shared/query/query_test.ts @@ -0,0 +1,1590 @@ +// utils/query/query_test.ts +/** + * Integration tests for query composition utilities + * + * Test Structure (Effect-TS inspired - precise behavioral contracts): + * + * 1. SCHEMA COMPOSITION + * - createQuerySchema() - combines pagination, filters, sorts, fields + * - Component disabling + * - Source detection (query, json, form) + * + * 2. VALIDATION ORDER + * - Pagination validation + * - Filter registry validation + * - Sort allowlist validation + * - Error aggregation + * + * 3. INTEGRATION SCENARIOS + * - Typical list endpoint queries + * - Cursor continuation + * - Complex filter combinations + * - Full QuerySpec output shape + */ + +import { describe, it } from '@std/testing/bdd' +import { expect } from '@std/expect' + +import { createQuerySpec } from './query.ts' +import type { + FilterRegistry, + EndpointQueryConfig, + QuerySpec, + FiltersNormalized, + PaginationNormalized, + SortsNormalized, +} from './schemas.ts' +import { createFieldsQueryAdapter } from './fields.ts' +import { createFiltersQueryAdapter } from './filtering.ts' +import { createPaginationQueryAdapter } from './pagination.ts' +import { createSortsQueryAdapter } from './sorting.ts' + +function createQuerySchema(options: { source: 'query' | 'json' | 'form'; config: EndpointQueryConfig }) { + return createQuerySpec(options.source, options.config) +} + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +const TEST_SECRET = 'test-secret-key-for-hmac-signing-minimum-32-chars' + +const TEST_FILTER_REGISTRY: FilterRegistry = { + status: { + operators: ['eq', 'ne', 'in', 'nin'], + type: 'enum', + values: ['draft', 'published', 'archived'], + arrayOperators: ['in', 'nin'] + }, + price: { + operators: ['eq', 'gt', 'gte', 'lt', 'lte'], + type: 'number' + }, + created_at: { + operators: ['eq', 'gt', 'gte', 'lt', 'lte'], + type: 'date' + }, + is_featured: { + operators: ['eq'], + type: 'boolean' + }, + user_id: { + operators: ['eq'], + type: 'uuid' + }, + title: { + operators: ['eq', 'contains', 'startswith'], + type: 'string' + } +} + +const TEST_SORT_ALLOWED = ['created_at', 'updated_at', 'title', 'price', 'id'] + +function makeConfig(overrides: Partial = {}): EndpointQueryConfig { + return { + pagination: { + limits: { + minLimit: 1, + maxLimit: 100, + defaultLimit: 20, + maxOffset: 10000, + cursorTTL: 3600 + }, + cursorSecret: TEST_SECRET + }, + filters: { + registry: TEST_FILTER_REGISTRY, + limits: { maxFilters: 10 } + }, + sorts: { + allowedFields: TEST_SORT_ALLOWED, + tiebreaker: 'id', + limits: { maxSorts: 3 } + }, + fields: { + allowedFields: ['id', 'title', 'status', 'price', 'created_at'], + disabled: false + }, + ...overrides + } +} + +// ============================================================================ +// 1. SCHEMA COMPOSITION +// ============================================================================ + +describe('createQuerySchema', () => { + describe('component composition', () => { + it('combines pagination, filters, sorts, fields schemas', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[status]': 'published', + sort: 'created_at:desc', + fields: 'id,title' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination).toBeDefined() + expect(result.data.filters).toBeDefined() + expect(result.data.sorts).toBeDefined() + expect(result.data.fields).toBeDefined() + } + }) + + it('respects disabled flag for filters', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + filters: { disabled: true } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[status]': 'published' // Should be ignored + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.filters).toBeNull() + } + }) + + it('respects disabled flag for sorts', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + sorts: { disabled: true } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + sort: 'created_at:desc' // Should be ignored + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.sorts).toBeNull() + } + }) + + it('respects disabled flag for fields', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + fields: { disabled: true } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + fields: 'id,title' // Should be ignored + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.fields).toBeNull() + } + }) + }) + + describe('source detection', () => { + it('uses query adapters for source="query"', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + // Query source expects bracket notation for filters + const result = schema.safeParse({ + 'filter[status]': 'published', + sort: 'created_at:desc', + offset: '0', + limit: '20' + }) + + expect(result.success).toBe(true) + }) + + it('uses json adapters for source="json"', () => { + const schema = createQuerySchema({ + source: 'json', + config: makeConfig() + }) + + // JSON source expects structured objects + const result = schema.safeParse({ + filters: [{ field: 'status', operator: 'eq', value: 'published' }], + sorts: [{ field: 'created_at', direction: 'desc' }], + pagination: { type: 'offset', offset: 0, limit: 20 } + }) + + expect(result.success).toBe(true) + }) + + it('uses form adapters for source="form"', () => { + const schema = createQuerySchema({ + source: 'form', + config: makeConfig() + }) + + // Form source similar to query + const result = schema.safeParse({ + 'filter[status]': 'published', + sort: 'created_at:desc', + offset: '0', + limit: '20' + }) + + expect(result.success).toBe(true) + }) + }) +}) + +// ============================================================================ +// 2. VALIDATION ORDER +// ============================================================================ + +describe('validation order', () => { + describe('pagination validation', () => { + it('validates limit bounds', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + pagination: { + limits: { minLimit: 1, maxLimit: 50, defaultLimit: 20, maxOffset: 10000, cursorTTL: 3600 } + } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '100' // Exceeds maxLimit of 50 + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(i => i.path.includes('limit'))).toBe(true) + } + }) + + it('validates offset bounds (DoS protection)', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + pagination: { + limits: { minLimit: 1, maxLimit: 100, defaultLimit: 20, maxOffset: 1000, cursorTTL: 3600 } + } + }) + }) + + const result = schema.safeParse({ + offset: '5000', // Exceeds maxOffset of 1000 + limit: '20' + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(i => i.path.includes('offset'))).toBe(true) + } + }) + + it('uses default limit when not provided', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + pagination: { + limits: { minLimit: 1, maxLimit: 100, defaultLimit: 25, maxOffset: 10000, cursorTTL: 3600 } + } + }) + }) + + const result = schema.safeParse({ + offset: '0' + // No limit provided + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination.limit).toBe(25) + } + }) + }) + + describe('filter registry validation', () => { + it('rejects unknown filter fields', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[unknown_field]': 'value' + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(i => + i.message.includes('not filterable') + )).toBe(true) + } + }) + + it('rejects invalid filter operators', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + // status only allows eq, ne, in, nin - not 'gt' + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[status][gt]': 'published' + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(i => + i.message.includes('not allowed') + )).toBe(true) + } + }) + + it('coerces filter values based on field type', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[price][gte]': '50.5' + }) + + expect(result.success).toBe(true) + if (result.success) { + const priceFilter = result.data.filters?.find(f => f.field === 'price') + expect(priceFilter?.value).toBe(50.5) + expect(typeof priceFilter?.value).toBe('number') + } + }) + }) + + describe('sort allowlist validation', () => { + it('rejects unknown sort fields', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + sort: 'unknown_field:desc' + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some(i => + i.message.includes('not sortable') + )).toBe(true) + } + }) + + it('injects tiebreaker after sort validation', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + sort: 'created_at:desc' + }) + + expect(result.success).toBe(true) + if (result.success) { + // Should have user sort + tiebreaker + expect(result.data.sorts?.length).toBe(2) + expect(result.data.sorts?.[0].field).toBe('created_at') + expect(result.data.sorts?.[1].field).toBe('id') + expect(result.data.sorts?.[1].tiebreaker).toBe(true) + } + }) + }) + + describe('error aggregation', () => { + it('collects multiple validation errors', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + pagination: { + limits: { minLimit: 1, maxLimit: 50, defaultLimit: 20, maxOffset: 1000, cursorTTL: 3600 } + } + }) + }) + + const result = schema.safeParse({ + offset: '5000', // Invalid: exceeds maxOffset + limit: '200', // Invalid: exceeds maxLimit + 'filter[unknown]': 'value', // Invalid: unknown field + sort: 'bad_field:desc' // Invalid: unknown field + }) + + expect(result.success).toBe(false) + if (!result.success) { + // Should have multiple errors + expect(result.error.issues.length).toBeGreaterThan(1) + } + }) + }) +}) + +// ============================================================================ +// 3. INTEGRATION SCENARIOS +// ============================================================================ + +describe('integration scenarios', () => { + describe('typical list endpoint queries', () => { + it('parses: ?offset=0&limit=20&filter[status]=active&sort=created_at:desc', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[status]': 'published', + sort: 'created_at:desc' + }) + + expect(result.success).toBe(true) + if (result.success) { + const pagination = result.data.pagination + expect(pagination.type).toBe('offset') + if (pagination.type === 'offset') { + expect(pagination.offset).toBe(0) + expect(pagination.limit).toBe(20) + } + expect(result.data.filters?.[0].field).toBe('status') + expect(result.data.filters?.[0].value).toBe('published') + expect(result.data.sorts?.[0].field).toBe('created_at') + expect(result.data.sorts?.[0].direction).toBe('desc') + } + }) + + it('parses page-based pagination: ?page=3&per_page=25', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + page: '3', + per_page: '25' + }) + + expect(result.success).toBe(true) + if (result.success) { + const pagination = result.data.pagination + expect(pagination.type).toBe('offset') + if (pagination.type === 'offset') { + expect(pagination.offset).toBe(50) + expect(pagination.limit).toBe(25) + } + } + }) + + it('handles multiple filters on same field (range query)', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[price][gte]': '50', + 'filter[price][lte]': '200' + }) + + expect(result.success).toBe(true) + if (result.success) { + const priceFilters = result.data.filters?.filter(f => f.field === 'price') + expect(priceFilters?.length).toBe(2) + + const gteFilter = priceFilters?.find(f => f.operator === 'gte') + const lteFilter = priceFilters?.find(f => f.operator === 'lte') + expect(gteFilter?.value).toBe(50) + expect(lteFilter?.value).toBe(200) + } + }) + + it('handles array filter (in operator)', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[status][in]': 'draft,published' + }) + + expect(result.success).toBe(true) + if (result.success) { + const statusFilter = result.data.filters?.find(f => f.field === 'status') + expect(statusFilter?.operator).toBe('in') + expect(statusFilter?.value).toEqual(['draft', 'published']) + } + }) + }) + + describe('cursor continuation', () => { + it('parses cursor-based pagination: ?cursor=abc&limit=20', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + cursor: '', // Empty cursor = first page + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination.type).toBe('cursor') + expect(result.data.pagination.limit).toBe(20) + } + }) + }) + + describe('JSON body queries', () => { + it('parses structured JSON query', () => { + const schema = createQuerySchema({ + source: 'json', + config: makeConfig() + }) + + const result = schema.safeParse({ + pagination: { + type: 'offset', + offset: 40, + limit: 20 + }, + filters: [ + { field: 'status', operator: 'eq', value: 'published' }, + { field: 'price', operator: 'gte', value: 100 } + ], + sorts: [ + { field: 'created_at', direction: 'desc' } + ] + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination.type).toBe('offset') + expect(result.data.filters?.length).toBe(2) + expect(result.data.sorts?.[0].field).toBe('created_at') + } + }) + }) + + describe('QuerySpec output shape', () => { + it('produces complete QuerySpec structure', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '20', + limit: '10', + 'filter[status]': 'published', + sort: 'title:asc', + fields: 'id,title,status' + }) + + expect(result.success).toBe(true) + if (result.success) { + const spec: QuerySpec = result.data + + // Pagination + expect(spec.pagination).toBeDefined() + expect(spec.pagination.type).toBe('offset') + + // Filters (or null if disabled) + expect(spec.filters).toBeDefined() + expect(Array.isArray(spec.filters)).toBe(true) + + // Sorts (or null if disabled) + expect(spec.sorts).toBeDefined() + expect(Array.isArray(spec.sorts)).toBe(true) + + // Fields (or null if disabled) + expect(spec.fields).toBeDefined() + } + }) + + it('produces null for disabled components', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + filters: { disabled: true }, + sorts: { disabled: true }, + fields: { disabled: true } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination).toBeDefined() + expect(result.data.filters).toBeNull() + expect(result.data.sorts).toBeNull() + expect(result.data.fields).toBeNull() + } + }) + }) + + describe('defaults application', () => { + it('applies default filters when none provided', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + filters: { + registry: TEST_FILTER_REGISTRY, + defaults: [{ field: 'status', operator: 'eq', value: 'published' }] + } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20' + // No filters + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.filters?.[0].field).toBe('status') + expect(result.data.filters?.[0].value).toBe('published') + } + }) + + it('applies default sorts when none provided', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + sorts: { + allowedFields: TEST_SORT_ALLOWED, + defaults: [{ field: 'created_at', direction: 'desc', tiebreaker: false }], + tiebreaker: 'id' + } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20' + // No sort + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.sorts?.[0].field).toBe('created_at') + expect(result.data.sorts?.[0].direction).toBe('desc') + } + }) + }) +}) + +// ============================================================================ +// EDGE CASES +// ============================================================================ + +describe('edge cases', () => { + describe('empty input', () => { + it('handles completely empty query', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({}) + + expect(result.success).toBe(true) + if (result.success) { + const pagination = result.data.pagination + expect(pagination.type).toBe('offset') + if (pagination.type === 'offset') { + expect(pagination.offset).toBe(0) + } + } + }) + }) + + describe('whitespace handling', () => { + it('handles whitespace in filter values', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[title]': ' spaced value ' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.filters?.[0].value).toBe(' spaced value ') + } + }) + }) + + describe('special characters', () => { + it('handles unicode in filter values', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[title][contains]': '日本語' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.filters?.[0].value).toBe('日本語') + } + }) + }) + + describe('boundary values', () => { + it('handles minimum limit', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + pagination: { + limits: { minLimit: 1, maxLimit: 100, defaultLimit: 20, maxOffset: 10000, cursorTTL: 3600 } + } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '1' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination.limit).toBe(1) + } + }) + + it('handles maximum limit', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + pagination: { + limits: { minLimit: 1, maxLimit: 100, defaultLimit: 20, maxOffset: 10000, cursorTTL: 3600 } + } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '100' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination.limit).toBe(100) + } + }) + + it('handles zero offset', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + const pagination = result.data.pagination + expect(pagination.type).toBe('offset') + if (pagination.type === 'offset') { + expect(pagination.offset).toBe(0) + } + } + }) + }) + + describe('many items', () => { + it('handles maximum allowed filters', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + filters: { + registry: TEST_FILTER_REGISTRY, + limits: { maxFilters: 10 } + } + }) + }) + + // Create 10 filter params (the max) + const params: Record = { + offset: '0', + limit: '20' + } + for (let i = 0; i < 10; i++) { + params[`filter[title][eq]`] = `value${i}` // Will overwrite, just testing count + } + + const result = schema.safeParse({ + offset: '0', + limit: '20', + 'filter[status]': 'published', + 'filter[price][gte]': '10', + 'filter[price][lte]': '100' + }) + + expect(result.success).toBe(true) + }) + + it('handles maximum allowed sorts', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig({ + sorts: { + allowedFields: TEST_SORT_ALLOWED, + limits: { maxSorts: 3 }, + tiebreaker: 'id' + } + }) + }) + + const result = schema.safeParse({ + offset: '0', + limit: '20', + sort: 'created_at:desc,title:asc,price:desc' + }) + + expect(result.success).toBe(true) + }) + }) +}) + +// ============================================================================ +// PRODUCTION ENDPOINT CONFIGURATIONS (IMPORTANT - mirror real endpoints) +// ============================================================================ + +describe('production endpoint configurations', () => { + /** + * These tests mirror actual endpoint definitions to ensure test coverage + * matches real-world usage patterns. + */ + + describe('collections list endpoint pattern', () => { + /** + * Mirrors: functions/collections/endpoints/collections/list/definition.ts + */ + const collectionsFilterRegistry: FilterRegistry = { + visibility_code: { + operators: ['eq', 'in'], + type: 'string', + arrayOperators: ['in'], + }, + collection_type: { + operators: ['eq', 'in', 'is_null'], + type: 'string', + arrayOperators: ['in'], + }, + deleted_at: { + operators: ['is_null', 'is_not_null'], + type: 'date', + }, + created_at: { + operators: ['gt', 'gte', 'lt', 'lte'], + type: 'date', + }, + updated_at: { + operators: ['gt', 'gte', 'lt', 'lte'], + type: 'date', + }, + } + + const collectionsConfig: EndpointQueryConfig = { + filters: { + registry: collectionsFilterRegistry, + limits: { maxFilters: 10 }, + }, + fields: { + allowedFields: [ + 'id', 'realm_id', 'name', 'slug', 'description', + 'cover_image_url', 'collection_type', 'sort_order', + 'visibility_code', 'item_count', 'created_at', 'updated_at', + ], + disabled: true, // Return all fields by default + }, + sorts: { + tiebreaker: 'id', + allowedFields: ['created_at', 'updated_at', 'name', 'item_count', 'id'], + limits: { maxSorts: 3 }, + defaults: [ + { field: 'created_at', direction: 'desc' }, + ], + }, + pagination: { + cursorSecret: TEST_SECRET, + limits: { + defaultLimit: 50, + maxLimit: 100, + }, + }, + } + + it('parses typical collections list query', () => { + const schema = createQuerySchema({ + source: 'query', + config: collectionsConfig + }) + + const result = schema.safeParse({ + 'filter[visibility_code]': 'public', + sort: 'created_at:desc', + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.filters?.[0]).toEqual({ + field: 'visibility_code', + operator: 'eq', + value: 'public' + }) + expect(result.data.sorts?.[0].field).toBe('created_at') + expect(result.data.pagination.limit).toBe(20) + } + }) + + it('handles collection_type IN query', () => { + const schema = createQuerySchema({ + source: 'query', + config: collectionsConfig + }) + + const result = schema.safeParse({ + 'filter[collection_type][in]': 'reading_list,favorites', + limit: '50' + }) + + expect(result.success).toBe(true) + if (result.success) { + const typeFilter = result.data.filters?.find(f => f.field === 'collection_type') + expect(typeFilter?.operator).toBe('in') + expect(typeFilter?.value).toEqual(['reading_list', 'favorites']) + } + }) + + it('applies default sort when no sort provided', () => { + const schema = createQuerySchema({ + source: 'query', + config: collectionsConfig + }) + + const result = schema.safeParse({ + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + // Should have default sort + tiebreaker + expect(result.data.sorts?.length).toBeGreaterThanOrEqual(1) + expect(result.data.sorts?.[0].field).toBe('created_at') + expect(result.data.sorts?.[0].direction).toBe('desc') + } + }) + + it('handles null check for soft delete', () => { + const schema = createQuerySchema({ + source: 'query', + config: collectionsConfig + }) + + const result = schema.safeParse({ + 'filter[deleted_at]': 'null', + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.filters?.[0].operator).toBe('is_null') + } + }) + + it('respects maxLimit from config', () => { + const schema = createQuerySchema({ + source: 'query', + config: collectionsConfig + }) + + const result = schema.safeParse({ + limit: '200' // Exceeds maxLimit of 100 + }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('100') + } + }) + + it('respects defaultLimit when no limit provided', () => { + const schema = createQuerySchema({ + source: 'query', + config: collectionsConfig + }) + + const result = schema.safeParse({}) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.pagination.limit).toBe(50) // defaultLimit + } + }) + }) + + describe('date range filtering pattern', () => { + /** + * Common pattern for reporting/analytics endpoints + */ + const dateRangeConfig: EndpointQueryConfig = { + filters: { + registry: { + created_at: { + operators: ['gte', 'lt', 'between'], + type: 'date', + }, + status: { + operators: ['eq', 'in'], + type: 'enum', + values: ['pending', 'completed', 'failed'], + arrayOperators: ['in'], + }, + }, + limits: { maxFilters: 5 }, + }, + fields: { disabled: true }, + sorts: { + allowedFields: ['created_at', 'id'], + tiebreaker: 'id', + limits: { maxSorts: 1 }, + }, + pagination: { + limits: { defaultLimit: 100, maxLimit: 1000 }, + }, + } + + it('handles date range with gte/lt pattern', () => { + const schema = createQuerySchema({ + source: 'query', + config: dateRangeConfig + }) + + const result = schema.safeParse({ + 'filter[created_at][gte]': '2024-01-01', + 'filter[created_at][lt]': '2024-02-01', + limit: '100' + }) + + expect(result.success).toBe(true) + if (result.success) { + const dateFilters = result.data.filters?.filter(f => f.field === 'created_at') + expect(dateFilters?.length).toBe(2) + } + }) + + it('handles date range with between operator', () => { + const schema = createQuerySchema({ + source: 'query', + config: dateRangeConfig + }) + + const result = schema.safeParse({ + 'filter[created_at][between]': '2024-01-01,2024-12-31', + limit: '100' + }) + + expect(result.success).toBe(true) + if (result.success) { + const dateFilter = result.data.filters?.find(f => f.field === 'created_at') + expect(dateFilter?.operator).toBe('between') + expect(dateFilter?.value).toHaveLength(2) + } + }) + }) + + describe('search endpoint pattern', () => { + /** + * Pattern for text search with icontains + */ + const searchConfig: EndpointQueryConfig = { + filters: { + registry: { + query: { + operators: ['icontains'], + type: 'string', + }, + category: { + operators: ['eq', 'in'], + type: 'string', + arrayOperators: ['in'], + }, + }, + limits: { maxFilters: 3 }, + }, + fields: { disabled: true }, + sorts: { + allowedFields: ['relevance', 'created_at', 'id'], + tiebreaker: 'id', + limits: { maxSorts: 1 }, + }, + pagination: { + limits: { defaultLimit: 20, maxLimit: 100 }, + }, + } + + it('handles search query with icontains', () => { + const schema = createQuerySchema({ + source: 'query', + config: searchConfig + }) + + const result = schema.safeParse({ + 'filter[query][icontains]': 'batman', + 'filter[category][in]': 'comics,movies', + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + const queryFilter = result.data.filters?.find(f => f.field === 'query') + expect(queryFilter?.operator).toBe('icontains') + expect(queryFilter?.value).toBe('batman') + + const categoryFilter = result.data.filters?.find(f => f.field === 'category') + expect(categoryFilter?.value).toEqual(['comics', 'movies']) + } + }) + }) + + describe('cursor pagination production flow', () => { + it('handles full cursor pagination lifecycle', () => { + const schema = createQuerySchema({ + source: 'query', + config: makeConfig() + }) + + // First request - no cursor + const firstPage = schema.safeParse({ + cursor: '', + limit: '20' + }) + + expect(firstPage.success).toBe(true) + if (firstPage.success) { + const pagination = firstPage.data.pagination + expect(pagination.type).toBe('cursor') + if (pagination.type === 'cursor') { + expect(pagination.cursor).toBeUndefined() + } + } + + // Subsequent request - with cursor (simulated) + // In production, cursor would be generated from first response + // For this test, we verify the schema accepts cursor param + const nextPage = schema.safeParse({ + cursor: 'some-cursor-token', + limit: '20' + }) + + expect(nextPage.success).toBe(true) + if (nextPage.success) { + expect(nextPage.data.pagination.type).toBe('cursor') + // Cursor would be validated and decoded in production + } + }) + }) + + describe('disabled components behavior', () => { + it('ignores filters when filters.disabled=true', () => { + const schema = createQuerySchema({ + source: 'query', + config: { + ...makeConfig(), + filters: { disabled: true } + } + }) + + const result = schema.safeParse({ + 'filter[unknown_field]': 'value', // Would normally fail + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.filters).toBeNull() + } + }) + + it('ignores sorts when sorts.disabled=true', () => { + const schema = createQuerySchema({ + source: 'query', + config: { + ...makeConfig(), + sorts: { disabled: true } + } + }) + + const result = schema.safeParse({ + sort: 'unknown_field:desc', // Would normally fail + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.sorts).toBeNull() + } + }) + + it('ignores fields when fields.disabled=true', () => { + const schema = createQuerySchema({ + source: 'query', + config: { + ...makeConfig(), + fields: { disabled: true } + } + }) + + const result = schema.safeParse({ + fields: 'unknown,fields', // Would normally fail + limit: '20' + }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.fields).toBeNull() + } + }) + }) +}) + + +// ============================================================================ +// PROPERTY-BASED ROUND-TRIP TESTS +// ============================================================================ + +describe('property-based round-trip tests', () => { + /** + * These tests verify that encode(decode(x)) produces equivalent output. + * For adapters that are symmetric, decode(encode(x)) === x. + * + * Property-based approach: generate various valid inputs and verify + * the round-trip preserves semantic meaning. + */ + + describe('filters adapter round-trip', () => { + const adapter = createFiltersQueryAdapter() + + // Generate test cases programmatically + const filterCases: Array<{ name: string; encoded: Record; normalized: FiltersNormalized }> = [ + { + name: 'simple equality', + encoded: { 'filter[status][eq]': 'active' }, + normalized: [{ field: 'status', operator: 'eq', value: 'active' }] + }, + { + name: 'comparison operators', + encoded: { 'filter[price][gte]': '100', 'filter[price][lt]': '500' }, + normalized: [ + { field: 'price', operator: 'gte', value: '100' }, + { field: 'price', operator: 'lt', value: '500' } + ] + }, + { + name: 'array operator (in)', + encoded: { 'filter[status][in]': 'draft,published,archived' }, + // Note: Adapter decodes to string; array splitting happens in schema coercion + normalized: [{ field: 'status', operator: 'in', value: 'draft,published,archived' }] + }, + { + name: 'null check', + encoded: { 'filter[deleted_at]': 'null' }, + normalized: [{ field: 'deleted_at', operator: 'is_null' }] + }, + { + name: 'not null check', + encoded: { 'filter[verified_at]': 'not_null' }, + normalized: [{ field: 'verified_at', operator: 'is_not_null' }] + }, + ] + + filterCases.forEach(({ name, encoded, normalized }) => { + it(`decode → encode preserves ${name}`, () => { + const decoded = adapter.decode(encoded) + const reencoded = adapter.encode(decoded) + + // Decode the re-encoded to compare normalized forms + const redecoded = adapter.decode(reencoded) + + expect(redecoded).toEqual(decoded) + }) + + it(`encode → decode preserves ${name}`, () => { + const encoded2 = adapter.encode(normalized) + const decoded = adapter.decode(encoded2) + + expect(decoded).toEqual(normalized) + }) + }) + }) + + describe('sorts adapter round-trip', () => { + const adapter = createSortsQueryAdapter() + + const sortCases: Array<{ name: string; encoded: string; normalized: SortsNormalized }> = [ + { + name: 'single ascending', + encoded: 'created_at:asc', + normalized: [{ field: 'created_at', direction: 'asc', tiebreaker: false }] + }, + { + name: 'single descending', + encoded: 'updated_at:desc', + normalized: [{ field: 'updated_at', direction: 'desc', tiebreaker: false }] + }, + { + name: 'multiple sorts', + encoded: 'status:asc,created_at:desc', + normalized: [ + { field: 'status', direction: 'asc', tiebreaker: false }, + { field: 'created_at', direction: 'desc', tiebreaker: false } + ] + }, + { + name: 'with tiebreaker', + encoded: 'created_at:desc,id:desc', + normalized: [ + { field: 'created_at', direction: 'desc', tiebreaker: false }, + { field: 'id', direction: 'desc', tiebreaker: true } // Last sort can be tiebreaker + ] + }, + ] + + sortCases.forEach(({ name, encoded, normalized }) => { + it(`decode → encode preserves ${name}`, () => { + const decoded = adapter.decode(encoded) + const reencoded = adapter.encode(decoded) + + // Decode again and compare (tiebreaker may be normalized) + const redecoded = adapter.decode(reencoded) + + // Compare fields and directions (tiebreaker is runtime-determined) + expect(redecoded.map(s => ({ field: s.field, direction: s.direction }))) + .toEqual(decoded.map(s => ({ field: s.field, direction: s.direction }))) + }) + }) + }) + + describe('pagination adapter round-trip', () => { + const adapter = createPaginationQueryAdapter() + + const paginationCases: Array<{ name: string; encoded: Record; normalized: Partial }> = [ + { + name: 'offset mode', + encoded: { offset: '20', limit: '10' }, + normalized: { type: 'offset', offset: 20, limit: 10 } + }, + { + name: 'cursor mode (first page)', + encoded: { cursor: '', limit: '20' }, + normalized: { type: 'cursor', limit: 20 } + }, + { + name: 'default offset', + encoded: { limit: '15' }, + normalized: { type: 'offset', offset: 0, limit: 15 } + }, + ] + + paginationCases.forEach(({ name, encoded, normalized }) => { + it(`decode produces expected shape for ${name}`, () => { + const decoded = adapter.decode(encoded) + + expect(decoded.type).toBe(normalized.type) + expect(decoded.limit).toBe(normalized.limit) + + if (normalized.type === 'offset' && 'offset' in normalized) { + expect((decoded as { offset: number }).offset).toBe(normalized.offset) + } + }) + }) + }) + + describe('fields adapter round-trip', () => { + const adapter = createFieldsQueryAdapter() + + const fieldsCases: Array<{ name: string; encoded: string; normalized: string[] }> = [ + { + name: 'single field', + encoded: 'id', + normalized: ['id'] + }, + { + name: 'multiple fields', + encoded: 'id,name,created_at', + normalized: ['id', 'name', 'created_at'] + }, + { + name: 'with whitespace', + encoded: ' id , name , status ', + normalized: ['id', 'name', 'status'] // Trimmed + }, + ] + + fieldsCases.forEach(({ name, encoded, normalized }) => { + it(`decode → encode preserves ${name}`, () => { + const decoded = adapter.decode(encoded) + + // Decoded fields should match normalized (after trimming) + if (decoded && decoded.type === 'simple') { + expect(decoded.fields.map(f => f.trim())).toEqual(normalized) + } + }) + }) + }) +}) + +// ============================================================================ +// RANDOMIZED PROPERTY TESTS +// ============================================================================ + +describe('randomized property tests', () => { + /** + * Generate random valid inputs and verify invariants hold. + * Not true property-based testing (no shrinking), but covers edge cases. + */ + + describe('filter field names', () => { + const validFieldNames = [ + 'a', + 'field', + 'field_name', + 'fieldName', + 'field123', + '_private', + 'CamelCase', + 'UPPER_CASE', + 'a'.repeat(50), // Long but reasonable + ] + + validFieldNames.forEach(fieldName => { + it(`preserves field name: ${fieldName.slice(0, 20)}...`, () => { + const adapter = createFiltersQueryAdapter() + const decoded = adapter.decode({ [`filter[${fieldName}]`]: 'value' }) + + expect(decoded).toHaveLength(1) + expect(decoded[0].field).toBe(fieldName) + }) + }) + }) + + describe('filter values with special characters', () => { + const specialValues = [ + 'hello world', + 'hello,world', // Commas + 'hello=world', // Equals + 'hello[world]', // Brackets + 'hello&world', // Ampersand + 'hello%20world', // URL encoded + 'a+b', // Plus + '100%', // Percent + ] + + specialValues.forEach(value => { + it(`preserves value: "${value}"`, () => { + const adapter = createFiltersQueryAdapter() + const decoded = adapter.decode({ 'filter[field][eq]': value }) + + expect(decoded).toHaveLength(1) + expect(decoded[0].value).toBe(value) + }) + }) + }) + + describe('sort field stability', () => { + it('maintains sort order through multiple round-trips', () => { + const adapter = createSortsQueryAdapter() + const original = 'created_at:desc,updated_at:asc,id:asc' + + let current = original + for (let i = 0; i < 5; i++) { + const decoded = adapter.decode(current) + current = adapter.encode(decoded) + } + + const finalDecoded = adapter.decode(current) + const originalDecoded = adapter.decode(original) + + expect(finalDecoded.map(s => s.field)).toEqual(originalDecoded.map(s => s.field)) + expect(finalDecoded.map(s => s.direction)).toEqual(originalDecoded.map(s => s.direction)) + }) + }) + + describe('pagination limit boundaries', () => { + const limits = [1, 10, 20, 50, 100, 500, 1000] + + limits.forEach(limit => { + it(`preserves limit=${limit} through round-trip`, () => { + const adapter = createPaginationQueryAdapter() + const decoded = adapter.decode({ limit: String(limit) }) + const encoded = adapter.encode(decoded) + const redecoded = adapter.decode(encoded) + + expect(redecoded.limit).toBe(limit) + }) + }) + }) +}) \ No newline at end of file diff --git a/packages/backend/src/query/schemas.ts b/supabase/functions/_shared/query/schemas.ts similarity index 81% rename from packages/backend/src/query/schemas.ts rename to supabase/functions/_shared/query/schemas.ts index e5d67d4..5d58d96 100644 --- a/packages/backend/src/query/schemas.ts +++ b/supabase/functions/_shared/query/schemas.ts @@ -11,6 +11,8 @@ */ import { z } from 'zod' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { Database } from '#shared/types/database.ts' import type { ValidationErrorDetail } from '../response/schemas.ts' // Re-export for convenience @@ -22,6 +24,13 @@ export type { ValidationErrorDetail } export const NonEmptyStringSchema = z.string().trim().min(1, 'Value cannot be empty') +// ============================================================================ +// SUPABASE CLIENT TYPES +// ============================================================================ + +export type SupabaseClientType = SupabaseClient +export type SupabaseClientSelectType = ReturnType['select']> + // ============================================================================ // FILTER SCHEMAS // ============================================================================ @@ -136,7 +145,7 @@ export type SortDirection = z.infer export const SortNormalizedSchema = z.object({ field: NonEmptyStringSchema, direction: SortDirectionSchema, - tiebreaker: z.boolean().optional().default(false) + tiebreaker: z.boolean().default(false) }) export type SortNormalized = z.infer @@ -183,18 +192,32 @@ export type FieldSelectionNormalized = z.infer */ export const OffsetPaginationNormalizedSchema = z.object({ type: z.literal('offset'), - limit: z.number().int().positive(), offset: z.number().int().min(0), + limit: z.number().int().positive(), }) export type OffsetPaginationNormalized = z.infer /** - * Cursor pagination (normalized) + * Cursor-based pagination normalized form + * + * Note: decodedCursor is OPTIONAL because: + * - During parsing/decoding: only the raw cursor string is available + * - During validation (superRefine): decodedCursor gets populated after signature verification + * - This matches the actual runtime behavior of the pagination flow */ export const CursorPaginationNormalizedSchema = z.object({ type: z.literal('cursor'), limit: z.number().int().positive(), + /** Raw cursor token (before decoding) */ cursor: NonEmptyStringSchema.optional(), - decodedCursor: CursorDataSchema + /** Decoded and verified cursor data (populated during validation, not parsing) */ + decodedCursor: CursorDataSchema.optional(), }) export type CursorPaginationNormalized = z.infer @@ -244,12 +274,12 @@ export type PaginationNormalized = z.infer */ export const PaginationConfigSchema = z.object({ limits: z.object({ - minLimit: z.number().int().positive().optional().default(1), - maxLimit: z.number().int().positive().optional().default(100), - defaultLimit: z.number().int().positive().optional().default(20), - maxOffset: z.number().int().positive().optional().default(1_000_000), - cursorTTL: z.number().int().positive().optional().default(86400), // 24 hours - }).optional().default({ + minLimit: z.number().int().positive().default(1), + maxLimit: z.number().int().positive().default(100), + defaultLimit: z.number().int().positive().default(20), + maxOffset: z.number().int().positive().default(1_000_000), + cursorTTL: z.number().int().positive().default(86400), // 24 hours + }).default({ minLimit: 1, maxLimit: 100, defaultLimit: 20, @@ -269,11 +299,11 @@ export type PaginationConfig = z.infer export const FiltersConfigSchema = z.object({ registry: FilterRegistrySchema.optional(), defaults: FiltersNormalizedSchema.optional(), - mergeDefaults: z.boolean().optional().default(true), - disabled: z.boolean().optional().default(false), + mergeDefaults: z.boolean().default(true), + disabled: z.boolean().default(false), limits: z.object({ - maxFilters: z.number().int().positive().optional().default(20), - }).optional().default({ maxFilters: 20 }), + maxFilters: z.number().int().positive().default(20), + }).default({ maxFilters: 20 }), }) export type FiltersConfig = z.input @@ -286,12 +316,12 @@ export type FiltersConfig = z.input export const SortsConfigSchema = z.object({ tiebreaker: z.string().default('id'), allowedFields: z.array(z.string()).optional(), - mergeDefaults: z.boolean().optional().default(true), + mergeDefaults: z.boolean().default(true), defaults: SortsNormalizedSchema.optional(), - disabled: z.boolean().optional().default(false), + disabled: z.boolean().default(false), limits: z.object({ - maxSorts: z.number().int().positive().optional().default(5), - }).optional().default({ maxSorts: 5 }), + maxSorts: z.number().int().positive().default(5), + }).default({ maxSorts: 5 }), }) export type SortsConfig = z.input diff --git a/packages/backend/src/query/sorting.ts b/supabase/functions/_shared/query/sorting.ts similarity index 68% rename from packages/backend/src/query/sorting.ts rename to supabase/functions/_shared/query/sorting.ts index 9aa7b63..8a0551b 100644 --- a/packages/backend/src/query/sorting.ts +++ b/supabase/functions/_shared/query/sorting.ts @@ -12,7 +12,7 @@ import { z } from 'zod' -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '../endpoint/schemas.ts' +import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '#shared/server/schemas.ts' import { SortsNormalizedSchema, type SortNormalized, @@ -78,44 +78,57 @@ function parseColonSyntax(data: string | undefined): SortsNormalized { return sorts } +/** + * Encode sorts back to colon syntax + * Used for round-trip testing + */ +function encodeColonSyntax(sorts: SortsNormalized): string { + return sorts + .filter(s => !s.tiebreaker) // Don't encode tiebreakers (they're added by schema) + .map(s => `${s.field}:${s.direction}`) + .join(',') +} + // ============================================================================ -// SOURCE ADAPTERS +// SOURCE ADAPTERS (unidirectional - decode only) // ============================================================================ - /** - * Query parameter adapter (uses z.codec) + * Query parameter adapter (codec for round-trip testing) * Supports: ?sort=created_at:desc,id:asc * * @example * const adapter = createSortsQueryAdapter() + * + * // Decode * const normalized = adapter.decode({ sort: 'created_at:desc,id:asc' }) * // => [{ field: 'created_at', direction: 'desc' }, { field: 'id', direction: 'asc' }] + * + * // Encode (for testing) + * const wire = adapter.encode(normalized) + * // => { sort: 'created_at:desc,id:asc' } */ export function createSortsQueryAdapter() { return z.codec( - SortsQueryWire, // Input (wire) - SortsNormalizedSchema, // Output (normalized) + SortsQueryWire, + SortsNormalizedSchema, { - decode: (query) => { + decode: (query): SortsNormalized => { // Extract sort value (handle ZStringOrStringArray) const raw = Array.isArray(query.sort) ? query.sort[0] : query.sort - return parseColonSyntax(raw) + return raw ? parseColonSyntax(raw) : [] }, - encode: (normalized) => { - // Reverse transform: SortsNormalized -> query string - if (normalized.length === 0) return {} as z.infer - const sortString = normalized - .map(s => `${s.field}:${s.direction}`) - .join(',') - return { sort: sortString } as z.infer + encode: (sorts): z.input => { + const normalized = SortsNormalizedSchema.parse(sorts); + const encoded = encodeColonSyntax(normalized) + return encoded ? { sort: encoded } : {} } } ) } /** - * JSON body adapter (uses z.codec) + * JSON body adapter (codec for round-trip testing) * Expects: { sorts: [{ field: 'created_at', direction: 'desc' }] } * * @example @@ -126,21 +139,21 @@ export function createSortsQueryAdapter() { */ export function createSortsJsonAdapter() { return z.codec( - SortsJsonWire, // Input (wire) - SortsNormalizedSchema, // Output (normalized) + SortsJsonWire, + SortsNormalizedSchema, { - decode: (raw) => { + decode: (raw): SortsNormalized => { return raw.sorts as SortsNormalized }, - encode: (normalized) => { - return { sorts: normalized.map(s => Object.assign(s, { tiebreaker: false })) } + encode: (sorts): z.input => { + return { sorts: sorts } } } ) } /** - * FormData adapter (uses z.codec) + * FormData adapter (codec for round-trip testing) * Supports: sort=created_at:desc,id:asc * * @example @@ -151,8 +164,8 @@ export function createSortsJsonAdapter() { */ export function createSortsFormAdapter() { return z.codec( - SortsFormWire, // Input (wire) - SortsNormalizedSchema, // Output (normalized) + SortsFormWire, + SortsNormalizedSchema, { decode: (raw): SortsNormalized => { const sortValue = raw.sort @@ -164,12 +177,10 @@ export function createSortsFormAdapter() { return parseColonSyntax(sortStr) }, - encode: (normalized) => { - if (normalized.length === 0) return {} - const sortString = normalized - .map(s => `${s.field}:${s.direction}`) - .join(',') - return { sort: sortString } as z.infer + encode: (sorts): z.input => { + const normalized = SortsNormalizedSchema.parse(sorts); + const encoded = encodeColonSyntax(normalized) + return encoded ? { sort: encoded } : {} } } ) @@ -181,14 +192,17 @@ export function createSortsFormAdapter() { /** * Create endpoint-specific sorts schema with validation - * All validation happens in .superRefine() so middleware handles errors + * + * Note: This schema is unidirectional (decode only) because it adds + * non-reversible transformations (defaults, tiebreakers). Use the + * adapters directly for round-trip testing. * * @param config Configuration for sorts validation * @param config.source Input source type ('query' | 'json' | 'form') * @param config.allowedFields Array of sortable field names (allowlist) * @param config.limits Optional limits configuration (maxSorts) * @param config.tiebreaker Field to use as tiebreaker (default: 'id') - * @param config.defaultSort Default sort when none provided + * @param config.defaults Default sort when none provided * * @example * const schema = createSortsSchema({ @@ -196,32 +210,32 @@ export function createSortsFormAdapter() { * allowedFields: ['created_at', 'title', 'id'], * limits: { maxSorts: 3 }, * tiebreaker: 'id', - * defaultSort: [{ field: 'created_at', direction: 'desc' }] + * defaults: [{ field: 'created_at', direction: 'desc' }] * }) * * // Parse and validate * const sorts = schema.parse({ sort: 'created_at:desc' }) - * // => [{ field: 'created_at', direction: 'desc' }, { field: 'id', direction: 'asc' }] + * // => [{ field: 'created_at', direction: 'desc' }, { field: 'id', direction: 'asc', tiebreaker: true }] */ export function createSortsSchema(config: { source: 'query' | 'json' | 'form' } & SortsConfig) { - // When disabled, always return null - if (config.disabled) { - return z.null() - } - const adapter = config.source === 'query' ? createSortsQueryAdapter() : config.source === 'json' ? createSortsJsonAdapter() : createSortsFormAdapter() + + if (config.disabled) { + return adapter.transform(() => null) + } const allowSet = new Set(config.allowedFields) const maxSorts = config.limits?.maxSorts ?? 5 const tiebreaker = config.tiebreaker ?? 'id' - + return adapter - .transform(sorts => { + // Step 1: Apply defaults + .transform((sorts) => { // Use default if no sorts provided if (sorts.length === 0 && config.defaults) { return config.defaults @@ -229,21 +243,28 @@ export function createSortsSchema(config: { // Merge defaults if enabled if (config.mergeDefaults && config.defaults) { - const defaultSorts = config.defaults.filter(def => !sorts.some(s => s.field === def.field)) + const defaultSorts = config.defaults.filter( + def => !sorts.some(s => s.field === def.field) + ) return [...defaultSorts, ...sorts] } return sorts }) - .superRefine((sorts, ctx) => { + + // Step 2: Validate + .check((ctx) => { + const sorts = ctx.value + // Check sort count before adding tiebreaker (DoS protection) if (sorts.length > maxSorts) { - ctx.addIssue({ + ctx.issues.push({ code: "too_big", maximum: maxSorts, origin: 'array', path: [], - message: `Too many sorts: maximum ${maxSorts} allowed, got ${sorts.length}`, + message: `Too many sorts: max ${maxSorts}, got ${sorts.length}`, + input: sorts }) return // Don't continue } @@ -251,25 +272,36 @@ export function createSortsSchema(config: { // Validate allowed fields if (allowSet.size > 0) { sorts.forEach((sort, idx) => { - if (!allowSet!.has(sort.field)) { - ctx.addIssue({ + if (!allowSet.has(sort.field)) { + ctx.issues.push({ code: "custom", path: [idx, 'field'], message: `Field '${sort.field}' is not sortable. Allowed fields: ${config.allowedFields!.join(', ')}`, + input: sorts }) } }) } }) - .transform((sorts) => { - // Always add tiebreaker if not already present - const _sorts = sorts.map(s => Object.assign(s, { tiebreaker: s.field === tiebreaker })); - const hasTiebreaker = _sorts.some(s => s.tiebreaker) + + // Step 3: Add tiebreaker + .transform((sorts): SortsNormalized => { + // Mark existing tiebreaker field + const withTiebreaker = sorts.map(s => ({ + ...s, + tiebreaker: s.field === tiebreaker + })) + + // Add tiebreaker if not already present + const hasTiebreaker = withTiebreaker.some(s => s.tiebreaker) if (!hasTiebreaker) { - _sorts.push({ field: tiebreaker, direction: 'asc' as SortDirection, tiebreaker: true }) - return _sorts + withTiebreaker.push({ + field: tiebreaker, + direction: 'asc' as SortDirection, + tiebreaker: true + }) } - return _sorts + return withTiebreaker }) -} \ No newline at end of file +} diff --git a/supabase/functions/_shared/query/sorting_test.ts b/supabase/functions/_shared/query/sorting_test.ts new file mode 100644 index 0000000..22426de --- /dev/null +++ b/supabase/functions/_shared/query/sorting_test.ts @@ -0,0 +1,687 @@ +// utils/query/sorting_test.ts +/** + * Comprehensive unit tests for sorting utilities + * + * Test Structure (Effect-TS inspired - precise behavioral contracts): + * + * 1. COLON SYNTAX PARSING + * - parseColonSyntax() - URL query extraction + * - Field extraction, direction parsing, whitespace handling + * + * 2. SOURCE ADAPTERS (decode/encode round-trips) + * - createSortsQueryAdapter + * - createSortsJsonAdapter + * - createSortsFormAdapter + * + * 3. VALIDATION (createSortsSchema) + * - DoS protection (maxSorts) + * - Field allowlist + * - Tiebreaker injection + * - Defaults and merging + */ + +import { describe, it } from '@std/testing/bdd' +import { expect } from '@std/expect' + +import { + createSortsQueryAdapter, + createSortsJsonAdapter, + createSortsFormAdapter, + createSortsSchema, +} from './sorting.ts' + +import type { SortsNormalized } from './schemas.ts' + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +const TEST_ALLOWED_FIELDS = ['created_at', 'updated_at', 'title', 'price', 'id', 'name'] + +// ============================================================================ +// 1. COLON SYNTAX PARSING (via query adapter) +// ============================================================================ + +describe('colon syntax parsing via query adapter', () => { + const adapter = createSortsQueryAdapter() + + describe('basic parsing', () => { + it('parses field:direction format', () => { + const result = adapter.decode({ sort: 'created_at:desc' }) + + expect(result).toHaveLength(1) + expect(result[0].field).toBe('created_at') + expect(result[0].direction).toBe('desc') + }) + + it('parses comma-separated multiple sorts', () => { + const result = adapter.decode({ sort: 'created_at:desc,title:asc' }) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ field: 'created_at', direction: 'desc', tiebreaker: false }) + expect(result[1]).toEqual({ field: 'title', direction: 'asc', tiebreaker: false }) + }) + + it('defaults direction to asc when omitted', () => { + const result = adapter.decode({ sort: 'created_at' }) + + expect(result[0].direction).toBe('asc') + }) + + it('handles whitespace around segments', () => { + const result = adapter.decode({ sort: ' created_at:desc , title:asc ' }) + + expect(result).toHaveLength(2) + expect(result[0].field).toBe('created_at') + expect(result[1].field).toBe('title') + }) + }) + + describe('direction validation', () => { + it('accepts asc direction', () => { + const result = adapter.decode({ sort: 'created_at:asc' }) + + expect(result[0].direction).toBe('asc') + }) + + it('accepts desc direction', () => { + const result = adapter.decode({ sort: 'created_at:desc' }) + + expect(result[0].direction).toBe('desc') + }) + + it('skips invalid directions (silent ignore for forward compatibility)', () => { + const result = adapter.decode({ sort: 'created_at:invalid,title:asc' }) + + // Design decision: Invalid direction segments are silently skipped rather than + // causing an error. This allows adding new direction keywords (e.g., 'nullsfirst') + // in the future without breaking existing clients. Validation layer can enforce + // strict checking if needed. + expect(result).toHaveLength(1) + expect(result[0].field).toBe('title') + }) + + it('normalizes direction to lowercase', () => { + const result = adapter.decode({ sort: 'created_at:DESC' }) + + expect(result[0].direction).toBe('desc') + }) + + it('normalizes mixed case direction', () => { + const result = adapter.decode({ sort: 'created_at:Asc' }) + + expect(result[0].direction).toBe('asc') + }) + }) + + describe('edge cases', () => { + it('returns empty array for empty string', () => { + const result = adapter.decode({ sort: '' }) + + expect(result).toEqual([]) + }) + + it('returns empty array for undefined', () => { + const result = adapter.decode({}) + + expect(result).toEqual([]) + }) + + it('handles field names with underscores', () => { + const result = adapter.decode({ sort: 'created_at:desc' }) + + expect(result[0].field).toBe('created_at') + }) + + it('handles field names with numbers', () => { + const result = adapter.decode({ sort: 'field1:asc' }) + + expect(result[0].field).toBe('field1') + }) + + it('skips empty segments from trailing commas', () => { + const result = adapter.decode({ sort: 'created_at:desc,' }) + + expect(result).toHaveLength(1) + expect(result[0].field).toBe('created_at') + }) + + it('skips empty segments from leading commas', () => { + const result = adapter.decode({ sort: ',created_at:desc' }) + + expect(result).toHaveLength(1) + }) + + it('skips empty segments from multiple commas', () => { + const result = adapter.decode({ sort: 'created_at:desc,,title:asc' }) + + expect(result).toHaveLength(2) + }) + + it('handles whitespace-only sort value', () => { + const result = adapter.decode({ sort: ' ' }) + + expect(result).toEqual([]) + }) + }) +}) + +// ============================================================================ +// 2. SOURCE ADAPTERS +// ============================================================================ + +describe('createSortsQueryAdapter', () => { + const adapter = createSortsQueryAdapter() + + describe('decode', () => { + it('parses sort query parameter', () => { + const result = adapter.decode({ sort: 'created_at:desc,id:asc' }) + + expect(result).toEqual([ + { field: 'created_at', direction: 'desc', tiebreaker: false }, + { field: 'id', direction: 'asc', tiebreaker: false } + ]) + }) + + it('handles ZStringOrStringArray (takes first)', () => { + const result = adapter.decode({ sort: ['created_at:desc', 'ignored:asc'] }) + + expect(result).toEqual([ + { field: 'created_at', direction: 'desc', tiebreaker: false } + ]) + }) + + it('returns empty array when no sort', () => { + const result = adapter.decode({ other: 'param' }) + + expect(result).toEqual([]) + }) + }) + + describe('encode', () => { + it('joins sorts with field:direction,field:direction', () => { + const result = adapter.encode([ + { field: 'created_at', direction: 'desc', tiebreaker: false }, + { field: 'id', direction: 'asc', tiebreaker: true } + ]) + + expect(result).toEqual({ sort: 'created_at:desc,id:asc' }) + }) + + it('returns empty object for empty sorts', () => { + const result = adapter.encode([]) + + expect(result).toEqual({}) + }) + }) + + describe('round-trip', () => { + const testCases: SortsNormalized[] = [ + [{ field: 'created_at', direction: 'desc', tiebreaker: false }], + [{ field: 'id', direction: 'asc', tiebreaker: true }], + [ + { field: 'created_at', direction: 'desc', tiebreaker: false }, + { field: 'title', direction: 'asc', tiebreaker: false }, + { field: 'id', direction: 'asc', tiebreaker: true } + ] + ] + + testCases.forEach((sorts, i) => { + it(`preserves sorts through encode/decode (case ${i + 1})`, () => { + const encoded = adapter.encode(sorts) + const decoded = adapter.decode(encoded) + + // Note: tiebreaker flag is not preserved through URL round-trip + // (it's set by validation layer, not parsing) + expect(decoded.map(s => ({ field: s.field, direction: s.direction }))).toEqual( + sorts.map(s => ({ field: s.field, direction: s.direction })) + ) + }) + }) + }) +}) + +describe('createSortsJsonAdapter', () => { + const adapter = createSortsJsonAdapter() + + describe('decode', () => { + it('extracts sorts array from { sorts: [...] }', () => { + const result = adapter.decode({ + sorts: [ + { field: 'created_at', direction: 'desc' } + ] + }) + + expect(result).toEqual([ + { field: 'created_at', direction: 'desc', tiebreaker: false } + ]) + }) + + it('defaults to empty array when sorts missing', () => { + const result = adapter.decode({}) + + expect(result).toEqual([]) + }) + }) + + describe('encode', () => { + it('wraps normalized sorts in { sorts: [...] }', () => { + const result = adapter.encode([ + { field: 'created_at', direction: 'desc', tiebreaker: false } + ]) + + expect(result).toEqual({ + sorts: [{ field: 'created_at', direction: 'desc', tiebreaker: false }] + }) + }) + }) +}) + +describe('createSortsFormAdapter', () => { + const adapter = createSortsFormAdapter() + + describe('decode', () => { + it('parses sort from form data', () => { + const result = adapter.decode({ sort: 'created_at:desc' }) + + expect(result).toEqual([ + { field: 'created_at', direction: 'desc', tiebreaker: false } + ]) + }) + + it('handles array value (takes first)', () => { + const result = adapter.decode({ sort: ['created_at:desc', 'title:asc'] }) + + expect(result).toEqual([ + { field: 'created_at', direction: 'desc', tiebreaker: false } + ]) + }) + + it('returns empty array when no sort', () => { + const result = adapter.decode({}) + + expect(result).toEqual([]) + }) + }) + + describe('encode', () => { + it('encodes sorts to form data format', () => { + const result = adapter.encode([ + { field: 'created_at', direction: 'desc', tiebreaker: false } + ]) + + expect(result).toEqual({ sort: 'created_at:desc' }) + }) + }) +}) + +// ============================================================================ +// 3. VALIDATION (createSortsSchema) +// ============================================================================ + +describe('createSortsSchema', () => { + describe('DoS protection', () => { + it('rejects when sorts exceed maxSorts limit', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + limits: { maxSorts: 2 } + }) + + const result = schema.safeParse({ sort: 'created_at:desc,title:asc,price:desc' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('maximum 2 allowed') + } + }) + + it('counts user sorts before adding tiebreaker', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + limits: { maxSorts: 2 }, + tiebreaker: 'id' + }) + + // 2 user sorts should pass, tiebreaker added after + const result = schema.safeParse({ sort: 'created_at:desc,title:asc' }) + + expect(result.success).toBe(true) + if (result.success) { + // Tiebreaker is added after validation + const data = result.data! + expect(data.length).toBe(3) + expect(data[2].field).toBe('id') + } + }) + + it('uses default maxSorts=5 when not configured', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS + }) + + // 5 sorts should pass + const result = schema.safeParse({ + sort: 'created_at:desc,updated_at:asc,title:desc,price:asc,name:desc' + }) + + expect(result.success).toBe(true) + }) + }) + + describe('field allowlist', () => { + it('rejects fields not in allowedFields', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: ['created_at', 'id'] + }) + + const result = schema.safeParse({ sort: 'unknown_field:desc' }) + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toContain('not sortable') + } + }) + + it('allows any field when allowedFields empty', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: [] + }) + + const result = schema.safeParse({ sort: 'anything:desc' }) + + expect(result.success).toBe(true) + }) + + it('provides helpful error message listing allowed fields', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: ['created_at', 'title'] + }) + + const result = schema.safeParse({ sort: 'unknown:desc' }) + + expect(result.success).toBe(false) + if (!result.success) { + const msg = result.error.issues[0].message + expect(msg).toContain('created_at') + expect(msg).toContain('title') + } + }) + }) + + describe('tiebreaker injection', () => { + it('adds tiebreaker when not present in user sorts', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + tiebreaker: 'id' + }) + + const result = schema.safeParse({ sort: 'created_at:desc' }) + + expect(result.success).toBe(true) + if (result.success) { + const data = result.data! + expect(data).toHaveLength(2) + expect(data[1]).toEqual({ + field: 'id', + direction: 'asc', + tiebreaker: true + }) + } + }) + + it('marks existing sort as tiebreaker if it matches', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + tiebreaker: 'id' + }) + + const result = schema.safeParse({ sort: 'created_at:desc,id:desc' }) + + expect(result.success).toBe(true) + if (result.success) { + const data = result.data! + expect(data).toHaveLength(2) + expect(data[1].field).toBe('id') + expect(data[1].tiebreaker).toBe(true) + expect(data[1].direction).toBe('desc') + } + }) + + it('uses configured tiebreaker field (default: id)', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: [...TEST_ALLOWED_FIELDS, 'uuid'], + tiebreaker: 'uuid' + }) + + const result = schema.safeParse({ sort: 'created_at:desc' }) + + expect(result.success).toBe(true) + if (result.success) { + const data = result.data! + expect(data[1].field).toBe('uuid') + } + }) + + it('tiebreaker has direction asc when auto-added', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + tiebreaker: 'id' + }) + + const result = schema.safeParse({ sort: 'created_at:desc' }) + + expect(result.success).toBe(true) + if (result.success) { + const data = result.data! + expect(data[1].direction).toBe('asc') + } + }) + + it('does not duplicate tiebreaker', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + tiebreaker: 'id' + }) + + const result = schema.safeParse({ sort: 'id:desc' }) + + expect(result.success).toBe(true) + if (result.success) { + const data = result.data! + expect(data).toHaveLength(1) + expect(data[0].tiebreaker).toBe(true) + } + }) + + it('adds tiebreaker even when tiebreaker field not explicitly in allowedFields', () => { + // This tests the common case where 'id' is always a valid sort + // even if not listed in the public allowedFields + const schema = createSortsSchema({ + source: 'query', + allowedFields: ['created_at', 'title'], // 'id' not listed + tiebreaker: 'id' + }) + + const result = schema.safeParse({ sort: 'created_at:desc' }) + + // Behavior depends on implementation - document actual behavior + // If tiebreaker is always added regardless of allowlist: + expect(result.success).toBe(true) + if (result.success) { + const data = result.data! + expect(data.some(s => s.field === 'id')).toBe(true) + } + }) + }) + + describe('defaults', () => { + it('uses defaults when no user sorts', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + defaults: [{ field: 'created_at', direction: 'desc', tiebreaker: false }], + tiebreaker: 'id' + }) + + const result = schema.safeParse({}) + + expect(result.success).toBe(true) + if (result.success) { + const data = result.data! + expect(data[0].field).toBe('created_at') + expect(data[0].direction).toBe('desc') + } + }) + + it('merges defaults when mergeDefaults=true', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + defaults: [{ field: 'created_at', direction: 'desc', tiebreaker: false }], + mergeDefaults: true, + tiebreaker: 'id' + }) + + const result = schema.safeParse({ sort: 'title:asc' }) + + expect(result.success).toBe(true) + if (result.success) { + // Default first, then user sort, then tiebreaker + const data = result.data! + expect(data.map(s => s.field)).toContain('created_at') + expect(data.map(s => s.field)).toContain('title') + } + }) + + it('skips duplicate fields when merging', () => { + const schema = createSortsSchema({ + source: 'query', + allowedFields: TEST_ALLOWED_FIELDS, + defaults: [{ field: 'created_at', direction: 'desc', tiebreaker: false }], + mergeDefaults: true, + tiebreaker: 'id' + }) + + // User provides same field as default + const result = schema.safeParse({ sort: 'created_at:asc' }) + + expect(result.success).toBe(true) + if (result.success) { + // Only one created_at (user's overrides default) + const data = result.data! + const createdAtSorts = data.filter(s => s.field === 'created_at') + expect(createdAtSorts).toHaveLength(1) + } + }) + }) + + describe('disabled mode', () => { + it('returns null when disabled=true', () => { + const schema = createSortsSchema({ + source: 'query', + disabled: true + }) + + const result = schema.safeParse({ sort: 'created_at:desc' }) + + expect(result.success).toBe(true) + if (result.success) { + expect(result.data).toBeNull() + } + }) + }) +}) + +// ============================================================================ +// EDGE CASES +// ============================================================================ + +describe('edge cases', () => { + describe('field name formats', () => { + const adapter = createSortsQueryAdapter() + + // Note: snake_case already tested in basic parsing section + // Focus on formats NOT tested elsewhere + + it('handles camelCase field names', () => { + const result = adapter.decode({ sort: 'createdAt:desc' }) + expect(result[0].field).toBe('createdAt') + }) + + it('handles field names starting with numbers', () => { + const result = adapter.decode({ sort: '123field:asc' }) + expect(result[0].field).toBe('123field') + }) + + it('handles single-character field names', () => { + const result = adapter.decode({ sort: 'x:asc' }) + expect(result[0].field).toBe('x') + }) + }) + + describe('colon edge cases', () => { + const adapter = createSortsQueryAdapter() + + it('handles multiple colons (takes first two parts)', () => { + const result = adapter.decode({ sort: 'field:asc:extra' }) + + expect(result[0].field).toBe('field') + expect(result[0].direction).toBe('asc') + }) + + it('handles field with no colon', () => { + const result = adapter.decode({ sort: 'fieldonly' }) + + expect(result[0].field).toBe('fieldonly') + expect(result[0].direction).toBe('asc') // default + }) + + it('skips segment with only colon', () => { + const result = adapter.decode({ sort: ':' }) + + expect(result).toEqual([]) + }) + + it('skips segment with colon but no field', () => { + const result = adapter.decode({ sort: ':asc' }) + + expect(result).toEqual([]) + }) + }) + + describe('special characters', () => { + const adapter = createSortsQueryAdapter() + + it('handles field names with dots', () => { + const result = adapter.decode({ sort: 'user.name:asc' }) + expect(result[0].field).toBe('user.name') + }) + + it('handles field names with hyphens', () => { + const result = adapter.decode({ sort: 'created-at:desc' }) + expect(result[0].field).toBe('created-at') + }) + }) + + describe('many sorts', () => { + const adapter = createSortsQueryAdapter() + + it('handles 10 sorts', () => { + const sort = Array.from({ length: 10 }, (_, i) => `field${i}:asc`).join(',') + const result = adapter.decode({ sort }) + + expect(result).toHaveLength(10) + }) + }) +}) diff --git a/packages/backend/src/response/errors.ts b/supabase/functions/_shared/response/errors.ts similarity index 98% rename from packages/backend/src/response/errors.ts rename to supabase/functions/_shared/response/errors.ts index 943255c..e2100e9 100644 --- a/packages/backend/src/response/errors.ts +++ b/supabase/functions/_shared/response/errors.ts @@ -2,8 +2,8 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { ErrorResponse, ErrorResult, ErrorsResult, ProblemDetails, ProblemDetailsWithErrors, ResponseResult, ValidationErrorDetail } from './schemas.ts' import { HTTPException } from 'hono/http-exception' -export const BASE_ERROR_URL = 'https://backend.okikio.dev/error' -export const BASE_DOCS_URL = 'https://docs.okikio.dev/errors' +export const BASE_ERROR_URL = 'https://api.popmodern.xyz/error' +export const BASE_DOCS_URL = 'https://docs.popmodern.xyz/errors' /** Canonical problem type URIs. */ export const ERROR_TYPES = { @@ -227,8 +227,8 @@ export function validationFailed( const errorCount = errors.length const defaultDetail = errorCount === 1 - ? 'Request validation failed on 1 field' - : `Request validation failed on ${errorCount} fields` + ? '1 validation error occurred' + : `${errorCount} validation errors occurred` return errs( ERROR_TYPES.VALIDATION_ERROR, @@ -633,4 +633,4 @@ export function exception(err: ErrorResponse) { */ export function isErrorResponse(response: ResponseResult): response is ErrorResponse { return response[2]['Content-Type'] === 'application/problem+json' -} \ No newline at end of file +} diff --git a/supabase/functions/_shared/response/errors_test.ts b/supabase/functions/_shared/response/errors_test.ts new file mode 100644 index 0000000..ea7a555 --- /dev/null +++ b/supabase/functions/_shared/response/errors_test.ts @@ -0,0 +1,731 @@ +// utils/response/errors_test.ts +/** + * Comprehensive unit tests for error response utilities + * + * Test Structure (Effect-TS inspired - precise behavioral contracts): + * + * 1. PROBLEM DETAILS CONSTRUCTION + * - baseProblem() - RFC 7807 compliance + * - Return tuple structure + * + * 2. CONVENIENCE FACTORIES + * - All status-specific factories (badRequest, notFound, etc.) + * - Factories with required extensions (methodNotAllowed, rateLimitExceeded) + * + * 3. MULTI-ERROR RESPONSES + * - validationFailed() - 422 with field errors + * - errs() - generic multi-error builder + * + * 4. TYPE GUARDS AND HELPERS + * - isErrorResponse() - discriminates error from success + * - exception() - HTTPException wrapper + * - extraProblemHeaders() - additional headers for certain statuses + */ + +import { describe, it } from '@std/testing/bdd' +import { expect } from '@std/expect' + +import { + baseProblem, + badRequest, + unauthorized, + forbidden, + notFound, + methodNotAllowed, + notAcceptable, + requestTimeout, + conflict, + gone, + preconditionFailed, + payloadTooLarge, + uriTooLong, + unsupportedMediaType, + rangeNotSatisfiable, + unprocessableEntity, + preconditionRequired, + rateLimitExceeded, + requestHeaderFieldsTooLarge, + unavailableForLegalReasons, + internalServerError, + notImplemented, + badGateway, + serviceUnavailable, + gatewayTimeout, + err, + errs, + validationFailed, + isErrorResponse, + exception, + extraProblemHeaders, + ERROR_TYPES, + ERROR_DOCS, + STATUS_TITLES, + titleFor, +} from './errors.ts' + +import { ok, paginate } from './success.ts' +import type { ErrorResult, ValidationErrorDetail } from './schemas.ts' + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +const TEST_INSTANCE = '/api/test/123' + +function makeValidationErrors(count: number): ValidationErrorDetail[] { + return Array.from({ length: count }, (_, i) => ({ + field: `field${i}`, + message: `Error message ${i}` + })) +} + +// ============================================================================ +// 1. PROBLEM DETAILS CONSTRUCTION +// ============================================================================ + +describe('baseProblem', () => { + describe('RFC 7807 compliance', () => { + it('includes type URI', () => { + const [body] = baseProblem(400, ERROR_TYPES.BAD_REQUEST, TEST_INSTANCE, 'Test error') + + expect(body.type).toBe(ERROR_TYPES.BAD_REQUEST) + }) + + it('includes title from STATUS_TITLES', () => { + const [body] = baseProblem(404, ERROR_TYPES.NOT_FOUND, TEST_INSTANCE, 'Test error') + + expect(body.title).toBe('Not Found') + }) + + it('includes status code', () => { + const [body] = baseProblem(500, ERROR_TYPES.INTERNAL_SERVER_ERROR, TEST_INSTANCE, 'Test error') + + expect(body.status).toBe(500) + }) + + it('includes detail message', () => { + const [body] = baseProblem(400, ERROR_TYPES.BAD_REQUEST, TEST_INSTANCE, 'Custom detail message') + + expect(body.detail).toBe('Custom detail message') + }) + + it('includes instance (request path)', () => { + const [body] = baseProblem(400, ERROR_TYPES.BAD_REQUEST, '/api/users/456', 'Test error') + + expect(body.instance).toBe('/api/users/456') + }) + + it('includes timestamp', () => { + const before = new Date() + const [body] = baseProblem(400, ERROR_TYPES.BAD_REQUEST, TEST_INSTANCE, 'Test error') + const after = new Date() + + expect(body.timestamp).toBeInstanceOf(Date) + expect(body.timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime()) + expect(body.timestamp.getTime()).toBeLessThanOrEqual(after.getTime()) + }) + + it('includes docs URL when provided', () => { + const [body] = baseProblem( + 400, + ERROR_TYPES.BAD_REQUEST, + TEST_INSTANCE, + 'Test error', + ERROR_DOCS.BAD_REQUEST + ) + + expect(body.docs).toBe(ERROR_DOCS.BAD_REQUEST) + }) + + it('omits docs when not provided', () => { + const [body] = baseProblem(400, ERROR_TYPES.BAD_REQUEST, TEST_INSTANCE, 'Test error') + + expect(body.docs).toBeUndefined() + }) + + it('includes extensions when provided', () => { + const [body] = baseProblem( + 400, + ERROR_TYPES.BAD_REQUEST, + TEST_INSTANCE, + 'Test error', + undefined, + { customField: 'custom value', count: 42 } + ) + + expect((body as Record).customField).toBe('custom value') + expect((body as Record).count).toBe(42) + }) + }) + + describe('return tuple structure', () => { + it('returns [body, status, headers] tuple', () => { + const result = baseProblem(400, ERROR_TYPES.BAD_REQUEST, TEST_INSTANCE, 'Test error') + + expect(result).toHaveLength(3) + expect(typeof result[0]).toBe('object') + expect(result[1]).toBe(400) + expect(typeof result[2]).toBe('object') + }) + + it('sets Content-Type to application/problem+json', () => { + const [, , headers] = baseProblem(400, ERROR_TYPES.BAD_REQUEST, TEST_INSTANCE, 'Test error') + + expect(headers['Content-Type']).toBe('application/problem+json') + }) + }) +}) + +describe('titleFor', () => { + it('returns title for known status', () => { + expect(titleFor(200)).toBe('OK') + expect(titleFor(404)).toBe('Not Found') + expect(titleFor(500)).toBe('Internal Server Error') + }) + + it('returns "Error" for unknown status', () => { + expect(titleFor(999)).toBe('Error') + }) +}) + +// ============================================================================ +// 2. CONVENIENCE FACTORIES +// ============================================================================ + +describe('convenience error factories', () => { + // All factories follow the same pattern as baseProblem() + // We test the pattern once thoroughly, then spot-check representatives + + describe('factory pattern verification', () => { + // Test that all factories return the expected tuple structure + const allFactories = [ + { fn: badRequest, status: 400 }, + { fn: unauthorized, status: 401 }, + { fn: forbidden, status: 403 }, + { fn: notFound, status: 404 }, + { fn: notAcceptable, status: 406 }, + { fn: requestTimeout, status: 408 }, + { fn: conflict, status: 409 }, + { fn: gone, status: 410 }, + { fn: preconditionFailed, status: 412 }, + { fn: uriTooLong, status: 414 }, + { fn: rangeNotSatisfiable, status: 416 }, + { fn: unprocessableEntity, status: 422 }, + { fn: preconditionRequired, status: 428 }, + { fn: requestHeaderFieldsTooLarge, status: 431 }, + { fn: unavailableForLegalReasons, status: 451 }, + { fn: internalServerError, status: 500 }, + { fn: notImplemented, status: 501 }, + { fn: badGateway, status: 502 }, + { fn: gatewayTimeout, status: 504 }, + ] as const + + it('all factories return correct status codes', () => { + allFactories.forEach(({ fn, status }) => { + const [, resultStatus] = fn(TEST_INSTANCE, 'Test error') + expect(resultStatus).toBe(status) + }) + }) + + it('all factories return [body, status, headers] tuple', () => { + allFactories.forEach(({ fn }) => { + const result = fn(TEST_INSTANCE, 'Test error') + expect(result).toHaveLength(3) + expect(result[2]['Content-Type']).toBe('application/problem+json') + }) + }) + + it('all factories include RFC 7807 required fields', () => { + allFactories.forEach(({ fn }) => { + const [body] = fn(TEST_INSTANCE, 'Test error') + expect(body.type).toBeDefined() + expect(body.title).toBeDefined() + expect(body.status).toBeDefined() + expect(body.detail).toBeDefined() + expect(body.instance).toBeDefined() + }) + }) + }) + + describe('spot-check: client errors (4xx)', () => { + it('badRequest (400) - common validation error', () => { + const [body, status] = badRequest(TEST_INSTANCE, 'Invalid input') + + expect(status).toBe(400) + expect(body.type).toBe(ERROR_TYPES.BAD_REQUEST) + expect(body.title).toBe('Bad Request') + expect(body.detail).toBe('Invalid input') + }) + + it('notFound (404) - resource not found', () => { + const [body, status] = notFound('/api/users/999', 'User not found') + + expect(status).toBe(404) + expect(body.instance).toBe('/api/users/999') + }) + + it('unprocessableEntity (422) - semantic validation', () => { + const [body, status] = unprocessableEntity(TEST_INSTANCE, 'Email already exists') + + expect(status).toBe(422) + expect(body.detail).toBe('Email already exists') + }) + }) + + describe('spot-check: server errors (5xx)', () => { + it('internalServerError (500) - unexpected error', () => { + const [body, status] = internalServerError(TEST_INSTANCE, 'Unexpected error') + + expect(status).toBe(500) + expect(body.type).toBe(ERROR_TYPES.INTERNAL_SERVER_ERROR) + }) + }) + + describe('extensions support', () => { + it('all factories accept extensions', () => { + const [body] = badRequest(TEST_INSTANCE, 'Test', { customField: 'value' }) + expect((body as Record).customField).toBe('value') + }) + }) +}) + +describe('factories with required extensions', () => { + describe('methodNotAllowed', () => { + it('returns status 405', () => { + const [, status] = methodNotAllowed(TEST_INSTANCE, ['GET', 'POST']) + expect(status).toBe(405) + }) + + it('includes allowed methods array in extensions', () => { + const [body] = methodNotAllowed(TEST_INSTANCE, ['GET', 'POST', 'PUT']) + expect((body as Record).allowed).toEqual(['GET', 'POST', 'PUT']) + }) + + it('accepts custom detail message', () => { + const [body] = methodNotAllowed(TEST_INSTANCE, ['GET'], 'DELETE not allowed here') + expect(body.detail).toBe('DELETE not allowed here') + }) + }) + + describe('payloadTooLarge', () => { + it('returns status 413', () => { + const [, status] = payloadTooLarge(TEST_INSTANCE, 1024 * 1024) + expect(status).toBe(413) + }) + + it('includes limitBytes in extensions', () => { + const [body] = payloadTooLarge(TEST_INSTANCE, 5 * 1024 * 1024) + expect((body as Record).limitBytes).toBe(5 * 1024 * 1024) + }) + + it('works without limitBytes', () => { + const [body, status] = payloadTooLarge(TEST_INSTANCE) + expect(status).toBe(413) + expect((body as Record).limitBytes).toBeUndefined() + }) + }) + + describe('unsupportedMediaType', () => { + it('returns status 415', () => { + const [, status] = unsupportedMediaType(TEST_INSTANCE, ['application/json']) + expect(status).toBe(415) + }) + + it('includes supported media types in extensions', () => { + const [body] = unsupportedMediaType(TEST_INSTANCE, ['application/json', 'application/xml']) + expect((body as Record).supported).toEqual(['application/json', 'application/xml']) + }) + }) + + describe('rateLimitExceeded', () => { + it('returns status 429', () => { + const [, status] = rateLimitExceeded(TEST_INSTANCE, 60) + expect(status).toBe(429) + }) + + it('includes retryAfter in extensions', () => { + const [body] = rateLimitExceeded(TEST_INSTANCE, 120) + expect((body as Record).retryAfter).toBe(120) + }) + + it('includes retryAfter in detail message', () => { + const [body] = rateLimitExceeded(TEST_INSTANCE, 60) + expect(body.detail).toContain('60') + }) + }) + + describe('serviceUnavailable', () => { + it('returns status 503', () => { + const [, status] = serviceUnavailable(TEST_INSTANCE, 'database') + expect(status).toBe(503) + }) + + it('includes service name in extensions', () => { + const [body] = serviceUnavailable(TEST_INSTANCE, 'redis-cache') + expect((body as Record).service).toBe('redis-cache') + }) + + it('includes service in default detail message', () => { + const [body] = serviceUnavailable(TEST_INSTANCE, 'payment-gateway') + expect(body.detail).toContain('payment-gateway') + }) + + it('accepts custom detail message', () => { + const [body] = serviceUnavailable(TEST_INSTANCE, 'db', 'Custom unavailable message') + expect(body.detail).toBe('Custom unavailable message') + }) + }) +}) + +describe('err (generic factory)', () => { + it('delegates to appropriate factory for known status', () => { + const [body, status] = err(404, TEST_INSTANCE, 'Resource not found') + + expect(status).toBe(404) + expect(body.type).toBe(ERROR_TYPES.NOT_FOUND) + expect(body.title).toBe('Not Found') + }) + + it('handles 405 with empty allowed array', () => { + const [body] = err(405, TEST_INSTANCE, 'Method not allowed') + expect((body as Record).allowed).toEqual([]) + }) + + it('handles 429 with retryAfter from extensions', () => { + const [body] = err(429, TEST_INSTANCE, 'Rate limited', { retryAfter: 30 }) + expect((body as Record).retryAfter).toBe(30) + }) + + it('handles 429 with default retryAfter when not in extensions', () => { + const [body] = err(429, TEST_INSTANCE, 'Rate limited') + expect((body as Record).retryAfter).toBe(60) + }) + + it('handles 503 with service from extensions', () => { + const [body] = err(503, TEST_INSTANCE, 'Service down', { service: 'api' }) + expect((body as Record).service).toBe('api') + }) + + it('handles unknown status with generic fallback', () => { + const [body, status] = err(418 as any, TEST_INSTANCE, "I'm a teapot") + + expect(status).toBe(418) + expect(body.detail).toBe("I'm a teapot") + }) +}) + +// ============================================================================ +// 3. MULTI-ERROR RESPONSES +// ============================================================================ + +describe('validationFailed', () => { + it('returns 422 status', () => { + const [, status] = validationFailed(TEST_INSTANCE, makeValidationErrors(1)) + + expect(status).toBe(422) + }) + + it('includes errors array', () => { + const errors = makeValidationErrors(3) + const [body] = validationFailed(TEST_INSTANCE, errors) + + expect(body.errors).toEqual(errors) + }) + + it('formats detail as "1 validation error occurred" for single error', () => { + const [body] = validationFailed(TEST_INSTANCE, makeValidationErrors(1)) + + expect(body.detail).toBe('1 validation error occurred') + }) + + it('formats detail as "X validation errors occurred" for multiple errors', () => { + const [body] = validationFailed(TEST_INSTANCE, makeValidationErrors(5)) + + expect(body.detail).toBe('5 validation errors occurred') + }) + + it('accepts custom detail message', () => { + const [body] = validationFailed(TEST_INSTANCE, makeValidationErrors(1), 'Custom validation message') + + expect(body.detail).toBe('Custom validation message') + }) + + it('uses VALIDATION_ERROR type', () => { + const [body] = validationFailed(TEST_INSTANCE, makeValidationErrors(1)) + + expect(body.type).toBe(ERROR_TYPES.VALIDATION_ERROR) + }) + + it('handles empty validation errors array', () => { + // Edge case: what happens with zero errors? + // This documents actual behavior - may want to reject empty arrays + const [body] = validationFailed(TEST_INSTANCE, []) + + expect(body.errors).toEqual([]) + expect(body.detail).toBe('0 validation errors occurred') + }) +}) + +describe('errs', () => { + it('accepts custom status and type', () => { + const errors = makeValidationErrors(2) + const [body, status] = errs( + ERROR_TYPES.BAD_REQUEST, + 'Bad Request', + 400, + TEST_INSTANCE, + errors + ) + + expect(status).toBe(400) + expect(body.type).toBe(ERROR_TYPES.BAD_REQUEST) + }) + + it('includes all validation error details', () => { + const errors = [ + { field: 'email', message: 'Invalid email format' }, + { field: 'password', message: 'Too short' }, + { field: 'age', message: 'Must be positive' } + ] + + const [body] = errs( + ERROR_TYPES.UNPROCESSABLE_ENTITY, + 'Unprocessable Entity', + 422, + TEST_INSTANCE, + errors + ) + + expect(body.errors).toEqual(errors) + }) + + it('uses default detail based on error count', () => { + const [body] = errs( + ERROR_TYPES.VALIDATION_ERROR, + 'Validation Error', + 422, + TEST_INSTANCE, + makeValidationErrors(3) + ) + + expect(body.detail).toBe('3 errors occurred') + }) + + it('accepts custom detail', () => { + const [body] = errs( + ERROR_TYPES.VALIDATION_ERROR, + 'Validation Error', + 422, + TEST_INSTANCE, + makeValidationErrors(1), + 'Custom detail' + ) + + expect(body.detail).toBe('Custom detail') + }) + + it('includes docs when provided', () => { + const [body] = errs( + ERROR_TYPES.VALIDATION_ERROR, + 'Validation Error', + 422, + TEST_INSTANCE, + makeValidationErrors(1), + undefined, + ERROR_DOCS.VALIDATION_ERROR + ) + + expect(body.docs).toBe(ERROR_DOCS.VALIDATION_ERROR) + }) +}) + +// ============================================================================ +// 4. TYPE GUARDS AND HELPERS +// ============================================================================ + +describe('isErrorResponse', () => { + it('returns true for error tuples (problem+json)', () => { + const errorResult = badRequest(TEST_INSTANCE, 'Test error') + + expect(isErrorResponse(errorResult)).toBe(true) + }) + + it('returns false for success tuples (application/json)', () => { + const successResult = ok({ id: '123' }) + + expect(isErrorResponse(successResult)).toBe(false) + }) + + it('returns false for pagination results', () => { + const paginationResult = paginate('/api/items', [{ id: '1' }], { + hasMore: false, + limit: 20, + count: 1 + }) + + expect(isErrorResponse(paginationResult)).toBe(false) + }) + + it('works with validationFailed results', () => { + const validationResult = validationFailed(TEST_INSTANCE, makeValidationErrors(1)) + + expect(isErrorResponse(validationResult)).toBe(true) + }) + + it('correctly narrows type', () => { + const result: ErrorResult | ReturnType = badRequest(TEST_INSTANCE, 'error') + + if (isErrorResponse(result)) { + // TypeScript should know this is ErrorResult + const [body, status] = result + expect(body.type).toBeDefined() + expect(status).toBe(400) + } + }) +}) + +describe('exception', () => { + it('creates HTTPException with problem details', () => { + const errorResult = notFound(TEST_INSTANCE, 'Resource not found') + const httpException = exception(errorResult) + + expect(httpException).toBeInstanceOf(Error) + expect(httpException.status).toBe(404) + }) + + it('includes response with correct body', async () => { + const errorResult = badRequest(TEST_INSTANCE, 'Invalid input') + const httpException = exception(errorResult) + + // Explicitly verify response exists before accessing + expect(httpException.res).toBeDefined() + if (!httpException.res) { + throw new Error('Expected HTTPException to have a response') + } + + const body = await httpException.res.json() + expect(body.type).toBe(ERROR_TYPES.BAD_REQUEST) + expect(body.detail).toBe('Invalid input') + }) + + it('includes problem+json content type', () => { + const errorResult = forbidden(TEST_INSTANCE, 'Access denied') + const httpException = exception(errorResult) + + expect(httpException.res).toBeDefined() + expect(httpException.res?.headers.get('Content-Type')).toBe('application/problem+json') + }) +}) + +describe('extraProblemHeaders', () => { + it('returns Retry-After for 429 with retryAfter extension', () => { + const headers = extraProblemHeaders(429, { retryAfter: 60 }) + + expect(headers['Retry-After']).toBe('60') + }) + + it('returns Retry-After for 503 with retryAfter extension', () => { + const headers = extraProblemHeaders(503, { retryAfter: 120 }) + + expect(headers['Retry-After']).toBe('120') + }) + + it('returns empty object for 429 without retryAfter', () => { + const headers = extraProblemHeaders(429, {}) + + expect(headers).toEqual({}) + }) + + it('returns empty object for other status codes', () => { + const headers = extraProblemHeaders(400, { retryAfter: 60 }) + + expect(headers).toEqual({}) + }) + + it('returns empty object when no extensions', () => { + const headers = extraProblemHeaders(500) + + expect(headers).toEqual({}) + }) +}) + +// ============================================================================ +// EDGE CASES +// ============================================================================ + +describe('edge cases', () => { + describe('empty and special values', () => { + it('handles empty detail message', () => { + const [body] = badRequest(TEST_INSTANCE, '') + + expect(body.detail).toBe('') + }) + + it('handles empty instance', () => { + const [body] = badRequest('', 'Test error') + + expect(body.instance).toBe('') + }) + + it('handles very long detail message', () => { + const longMessage = 'x'.repeat(10000) + const [body] = badRequest(TEST_INSTANCE, longMessage) + + expect(body.detail).toBe(longMessage) + }) + }) + + describe('special characters', () => { + it('handles unicode in detail', () => { + const [body] = badRequest(TEST_INSTANCE, '日本語エラーメッセージ 🚫') + + expect(body.detail).toBe('日本語エラーメッセージ 🚫') + }) + + it('handles special characters in instance', () => { + const [body] = badRequest('/api/users?name=John%20Doe&filter[status]=active', 'Test') + + expect(body.instance).toBe('/api/users?name=John%20Doe&filter[status]=active') + }) + }) + + describe('extension edge cases', () => { + it('handles null in extensions', () => { + const [body] = badRequest(TEST_INSTANCE, 'Test', { nullField: null }) + + expect((body as Record).nullField).toBeNull() + }) + + it('handles nested objects in extensions', () => { + const [body] = badRequest(TEST_INSTANCE, 'Test', { + nested: { deep: { value: 42 } } + }) + + expect((body as Record).nested).toEqual({ deep: { value: 42 } }) + }) + + it('handles arrays in extensions', () => { + const [body] = badRequest(TEST_INSTANCE, 'Test', { + items: [1, 2, 3] + }) + + expect((body as Record).items).toEqual([1, 2, 3]) + }) + }) + + describe('validation errors edge cases', () => { + it('handles empty errors array', () => { + const [body] = validationFailed(TEST_INSTANCE, []) + + expect(body.errors).toEqual([]) + expect(body.detail).toBe('0 validation errors occurred') + }) + + it('handles many errors', () => { + const errors = makeValidationErrors(100) + const [body] = validationFailed(TEST_INSTANCE, errors) + + expect(body.errors).toHaveLength(100) + }) + }) +}) \ No newline at end of file diff --git a/packages/backend/src/response/index.ts b/supabase/functions/_shared/response/mod.ts similarity index 97% rename from packages/backend/src/response/index.ts rename to supabase/functions/_shared/response/mod.ts index 91a7480..0b08e6a 100644 --- a/packages/backend/src/response/index.ts +++ b/supabase/functions/_shared/response/mod.ts @@ -14,7 +14,7 @@ * - ESM-friendly, Deno v2 / TS strict, tree-shakeable. */ -export type * from "./schemas.ts"; +export * from "./schemas.ts"; export * from "./errors.ts"; export * from "./success.ts"; export * from "./status-codes.ts" diff --git a/packages/backend/src/response/schemas.ts b/supabase/functions/_shared/response/schemas.ts similarity index 98% rename from packages/backend/src/response/schemas.ts rename to supabase/functions/_shared/response/schemas.ts index 3fa6552..86c5cd9 100644 --- a/packages/backend/src/response/schemas.ts +++ b/supabase/functions/_shared/response/schemas.ts @@ -70,11 +70,17 @@ export const PaginationSchema = z.object({ hasMore: z.boolean(), limit: z.number().int().positive(), count: z.number().int().min(0), + // Cursor-based navigation nextCursor: z.string().optional(), prevCursor: z.string().optional(), + // Offset-based navigation offset: z.number().int().min(0).optional(), + nextOffset: z.number().int().min(0).optional(), + prevOffset: z.number().int().min(0).optional(), + // Totals total: z.number().int().min(0).optional(), approxTotal: z.number().int().min(0).optional(), + // Cursor expiration expiresAt: z.coerce.date().optional() }) diff --git a/packages/backend/src/response/status-codes.ts b/supabase/functions/_shared/response/status-codes.ts similarity index 100% rename from packages/backend/src/response/status-codes.ts rename to supabase/functions/_shared/response/status-codes.ts diff --git a/packages/backend/src/response/success.ts b/supabase/functions/_shared/response/success.ts similarity index 94% rename from packages/backend/src/response/success.ts rename to supabase/functions/_shared/response/success.ts index b33fe97..17b3823 100644 --- a/packages/backend/src/response/success.ts +++ b/supabase/functions/_shared/response/success.ts @@ -16,7 +16,7 @@ export function ok( export function ok( data: T, - statusCode: ContentfulStatusCode, + statusCode?: ContentfulStatusCode, meta?: M ): SuccessResult; @@ -115,7 +115,7 @@ export function paginate( // --- headers & links ------------------------------------------------------- const headers: StandardHeaders = {} const linkMap: LinkMap = { self: url }; - const linkHeaderParts: string[] = [] + const linkHeaderParts: string[] = []; // Self link (current page) if (pagination.offset !== undefined && pagination.limit) { @@ -138,29 +138,38 @@ export function paginate( if (pagination.prevCursor) { const prevUrl = buildCursorUrl(url, pagination.prevCursor, pagination.limit) linkHeaderParts.push(`<${prevUrl}>; rel="prev"`) - linkMap.next = prevUrl + linkMap.prev = prevUrl } // Offset links + extra headers if (pagination.offset !== undefined && pagination.limit) { - const firstOffset = 0 + const firstOffset = 0; const nextOffset = pagination.offset + pagination.limit const prevOffset = Math.max(pagination.offset - pagination.limit, 0) + const total = pagination.total + const approx = pagination.approxTotal + const firstUrl = buildOffsetUrl(url, firstOffset, pagination.limit); const nextUrl = buildOffsetUrl(url, nextOffset, pagination.limit); const prevUrl = buildOffsetUrl(url, prevOffset, pagination.limit); - linkMap.first = firstUrl; - linkMap.next ??= nextUrl; - linkMap.prev ??= prevUrl; - linkHeaderParts.push(`<${firstUrl}>; rel="first"`); - linkHeaderParts.push(`<${nextUrl}>; rel="next"`); - linkHeaderParts.push(`<${prevUrl}>; rel="prev"`); + linkMap.first = firstUrl; + + // prev: only when not on first page + if (pagination.offset > 0) { + linkHeaderParts.push(`<${prevUrl}>; rel="prev"`); + linkMap.prev ??= prevUrl; + } - const total = pagination.total - const approx = pagination.approxTotal + // next: only when we can prove there is a next page + // - prefer hasMore if provided + // - otherwise, if exact total is known, compute it + if (pagination.hasMore || (typeof total === "number" ? nextOffset < total : false)) { + linkHeaderParts.push(`<${nextUrl}>; rel="next"`); + linkMap.next ??= nextUrl; + } // De-facto admin-friendly counters // Prefer exact `total`; fall back to `approxTotal` if present. @@ -169,10 +178,12 @@ export function paginate( headers["X-Total-Count"] = String(totalCount) headers["X-Per-Page"] = String(pagination.limit) headers["X-Page"] = String(Math.floor((pagination.offset ?? 0) / pagination.limit) + 1) + // Only compute total pages when exact total is known if (typeof total === "number" && pagination.limit > 0) { headers["X-Total-Pages"] = String(Math.max(Math.ceil(total / pagination.limit), 1)) } + // Signal whether the server used exact or estimated counts headers["Preference-Applied"] = typeof total === "number" ? "count=exact" : "count=estimated" } @@ -188,7 +199,7 @@ export function paginate( } // Last link only when exact total is known (so it's meaningful) - if (typeof total === "number" && total >= 0) { + if (typeof total === "number" && total > 0) { const lastOffset = Math.max(total - pagination.limit, 0) const lastUrl = buildOffsetUrl(url, lastOffset, pagination.limit); linkHeaderParts.push(`<${lastUrl}>; rel="last"`); diff --git a/supabase/functions/_shared/response/success_test.ts b/supabase/functions/_shared/response/success_test.ts new file mode 100644 index 0000000..0514bb4 --- /dev/null +++ b/supabase/functions/_shared/response/success_test.ts @@ -0,0 +1,969 @@ +// utils/response/success_test.ts +/** + * Comprehensive unit tests for success response utilities + * + * Test Structure (Effect-TS inspired - precise behavioral contracts): + * + * 1. BASIC ENVELOPES + * - ok() - standard success responses + * - created() - 201 with Location header + * - accepted() - 202 for async operations + * - noContent() - 204 responses + * + * 2. PAGINATION RESPONSE + * - paginate() - cursor and offset modes + * - Link header generation (RFC 8288) + * - De-facto headers (X-Total-Count, X-Per-Page, etc.) + * - Standards headers (Range-Unit, Content-Range) + * + * 3. URL BUILDING + * - buildUrlWithParams() - query string manipulation + * - buildCursorUrl() - cursor pagination URLs + * - buildOffsetUrl() - offset pagination URLs + * + * 4. RESULT HELPERS + * - withHeaders() - merge extra headers + * - withMeta() - merge extra metadata + * - isSuccessResponse() - type guard + */ + +import { describe, it } from '@std/testing/bdd' +import { expect } from '@std/expect' + +import { + ok, + created, + accepted, + noContent, + paginate, + buildUrlWithParams, + buildCursorUrl, + buildOffsetUrl, + withHeaders, + withMeta, + isSuccessResponse, +} from './success.ts' + +import { badRequest } from './errors.ts' +import type { Pagination } from './schemas.ts' + +// ============================================================================ +// TEST FIXTURES +// ============================================================================ + +const TEST_URL = '/api/items' + +function makePagination(overrides: Partial = {}): Pagination { + return { + hasMore: false, + limit: 20, + count: 10, + ...overrides + } +} + +function makeItems(count: number) { + return Array.from({ length: count }, (_, i) => ({ id: `item-${i + 1}`, name: `Item ${i + 1}` })) +} + +// ============================================================================ +// 1. BASIC ENVELOPES +// ============================================================================ + +describe('ok', () => { + describe('status code handling', () => { + it('defaults to 200', () => { + const [, status] = ok({ id: '123' }) + + expect(status).toBe(200) + }) + + it('accepts explicit status code', () => { + const [, status] = ok({ id: '123' }, 201) + + expect(status).toBe(201) + }) + + it('returns undefined body for 204', () => { + const [body, status] = ok(null, 204) + + expect(status).toBe(204) + expect(body).toBeUndefined() + }) + + it('returns undefined body for 101', () => { + const [body, status] = ok(null, 101) + + expect(status).toBe(101) + expect(body).toBeUndefined() + }) + + it('returns undefined body for 205', () => { + const [body, status] = ok(null, 205) + + expect(status).toBe(205) + expect(body).toBeUndefined() + }) + + it('returns undefined body for 304', () => { + const [body, status] = ok(null, 304) + + expect(status).toBe(304) + expect(body).toBeUndefined() + }) + }) + + describe('envelope structure', () => { + it('wraps data in { data, meta }', () => { + const [body] = ok({ id: '123', name: 'Test' }) + + expect(body).toHaveProperty('data') + expect(body).toHaveProperty('meta') + expect(body.data).toEqual({ id: '123', name: 'Test' }) + }) + + it('includes timestamp in meta', () => { + const before = new Date() + const [body] = ok({ id: '123' }) + const after = new Date() + + expect(body.meta.timestamp).toBeDefined() + const timestamp = new Date(body.meta.timestamp) + expect(timestamp.getTime()).toBeGreaterThanOrEqual(before.getTime()) + expect(timestamp.getTime()).toBeLessThanOrEqual(after.getTime()) + }) + + it('merges custom meta', () => { + const [body] = ok({ id: '123' }, 200, { custom: 'value', count: 42 }) + + expect(body.meta.custom).toBe('value') + expect(body.meta.count).toBe(42) + expect(body.meta.timestamp).toBeDefined() + }) + }) + + describe('return tuple', () => { + it('returns [body, status, headers] tuple', () => { + const result = ok({ id: '123' }) + + expect(result).toHaveLength(3) + }) + + it('sets Content-Type to application/json', () => { + const [, , headers] = ok({ id: '123' }) + + expect(headers['Content-Type']).toBe('application/json') + }) + }) + + describe('data types', () => { + it('handles null data', () => { + const [body] = ok(null, 200) + + expect(body.data).toBeNull() + }) + + it('handles array data', () => { + const items = [{ id: '1' }, { id: '2' }] + const [body] = ok(items) + + expect(body.data).toEqual(items) + }) + + it('handles primitive data', () => { + const [body] = ok('string value') + + expect(body.data).toBe('string value') + }) + + it('handles nested objects', () => { + const data = { nested: { deep: { value: 42 } } } + const [body] = ok(data) + + expect(body.data).toEqual(data) + }) + }) +}) + +describe('created', () => { + it('returns 201 status', () => { + const [, status] = created({ id: '123' }) + + expect(status).toBe(201) + }) + + it('includes Location header when provided', () => { + const [, , headers] = created({ id: '123' }, '/api/resources/123') + + expect(headers.Location).toBe('/api/resources/123') + }) + + it('omits Location header when not provided', () => { + const [, , headers] = created({ id: '123' }) + + expect(headers.Location).toBeUndefined() + }) + + it('accepts custom meta', () => { + const [body] = created({ id: '123' }, undefined, { created: true }) + + expect(body.meta.created).toBe(true) + }) +}) + +describe('accepted', () => { + it('returns 202 status', () => { + const [, status] = accepted({ taskId: 'task-123' }) + + expect(status).toBe(202) + }) + + it('supports tracking metadata', () => { + const [body] = accepted( + { taskId: 'task-123' }, + { tracking: { taskId: 'task-123', status: 'queued' } } + ) + + expect(body.meta.tracking).toEqual({ taskId: 'task-123', status: 'queued' }) + }) +}) + +describe('noContent', () => { + it('returns 204 status', () => { + const [, status] = noContent() + + expect(status).toBe(204) + }) + + it('returns undefined body', () => { + const [body] = noContent() + + expect(body).toBeUndefined() + }) +}) + +// ============================================================================ +// 2. PAGINATION RESPONSE +// ============================================================================ + +describe('paginate', () => { + describe('cursor mode', () => { + it('includes nextCursor in meta when provided', () => { + const [body] = paginate(TEST_URL, makeItems(10), makePagination({ + hasMore: true, + nextCursor: 'cursor-abc' + })) + + expect(body.meta.pagination.nextCursor).toBe('cursor-abc') + }) + + it('includes prevCursor in meta when provided', () => { + const [body] = paginate(TEST_URL, makeItems(10), makePagination({ + prevCursor: 'cursor-xyz' + })) + + expect(body.meta.pagination.prevCursor).toBe('cursor-xyz') + }) + + it('builds Link header with rel="next" for nextCursor', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + hasMore: true, + nextCursor: 'cursor-next', + limit: 20 + })) + + expect(headers.Link).toContain('rel="next"') + expect(headers.Link).toContain('cursor=cursor-next') + }) + + it('builds Link header with rel="prev" for prevCursor', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + prevCursor: 'cursor-prev', + limit: 20 + })) + + expect(headers.Link).toContain('rel="prev"') + expect(headers.Link).toContain('cursor=cursor-prev') + }) + + it('includes expiresAt when provided', () => { + const expiresAt = new Date('2024-01-15T12:00:00Z') + const [body] = paginate(TEST_URL, makeItems(10), makePagination({ + expiresAt + })) + + expect(body.meta.pagination.expiresAt).toEqual(expiresAt) + }) + }) + + describe('offset mode', () => { + it('includes offset in meta', () => { + const [body] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 40, + limit: 20 + })) + + expect(body.meta.pagination.offset).toBe(40) + }) + + it('includes nextOffset when hasMore', () => { + const [body] = paginate(TEST_URL, makeItems(20), makePagination({ + offset: 40, + limit: 20, + hasMore: true, + nextOffset: 60 + })) + + expect(body.meta.pagination.hasMore).toBe(true) + }) + + it('builds Link header with rel="first"', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 40, + limit: 20 + })) + + expect(headers.Link).toContain('rel="first"') + expect(headers.Link).toContain('offset=0') + }) + + it('builds Link header with rel="last" when total known', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 40, + limit: 20, + total: 100 + })) + + expect(headers.Link).toContain('rel="last"') + expect(headers.Link).toContain('offset=80') // total 100, limit 20 -> last page at 80 + }) + + it('builds Link header with rel="next" when hasMore', () => { + const [, , headers] = paginate(TEST_URL, makeItems(20), makePagination({ + offset: 40, + limit: 20, + hasMore: true + })) + + expect(headers.Link).toContain('rel="next"') + expect(headers.Link).toContain('offset=60') + }) + + it('builds Link header with rel="prev" when offset > 0', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 40, + limit: 20 + })) + + expect(headers.Link).toContain('rel="prev"') + expect(headers.Link).toContain('offset=20') + }) + + it('omits rel="prev" when on first page', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20 + })) + + expect(headers.Link).not.toContain('rel="prev"') + }) + }) + + describe('de-facto headers (offset mode)', () => { + it('sets X-Total-Count from total', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20, + total: 100 + })) + + expect(headers['X-Total-Count']).toBe('100') + }) + + it('sets X-Total-Count from approxTotal when no exact total', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20, + approxTotal: 1000 + })) + + expect(headers['X-Total-Count']).toBe('1000') + }) + + it('sets X-Per-Page from limit', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 25, + total: 100 + })) + + expect(headers['X-Per-Page']).toBe('25') + }) + + it('sets X-Page (1-indexed)', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 40, + limit: 20, + total: 100 + })) + + expect(headers['X-Page']).toBe('3') // offset 40 / limit 20 + 1 = 3 + }) + + it('sets X-Total-Pages when exact total known', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20, + total: 95 + })) + + expect(headers['X-Total-Pages']).toBe('5') // ceil(95/20) = 5 + }) + + it('omits X-Total-Pages when only approxTotal', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20, + approxTotal: 1000 + })) + + expect(headers['X-Total-Pages']).toBeUndefined() + }) + + it('sets Preference-Applied: count=exact when total', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20, + total: 100 + })) + + expect(headers['Preference-Applied']).toBe('count=exact') + }) + + it('sets Preference-Applied: count=estimated when approxTotal only', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20, + approxTotal: 1000 + })) + + expect(headers['Preference-Applied']).toBe('count=estimated') + }) + }) + + describe('standards headers (offset mode, exact total)', () => { + it('sets Range-Unit: items', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20, + total: 100 + })) + + expect(headers['Range-Unit']).toBe('items') + }) + + it('sets Content-Range: start-end/total (inclusive end)', () => { + const [, , headers] = paginate(TEST_URL, makeItems(20), makePagination({ + offset: 40, + limit: 20, + total: 100 + })) + + expect(headers['Content-Range']).toBe('40-59/100') + }) + + it('handles Content-Range at end of results', () => { + const [, , headers] = paginate(TEST_URL, makeItems(15), makePagination({ + offset: 85, + limit: 20, + total: 100 + })) + + expect(headers['Content-Range']).toBe('85-99/100') + }) + + it('handles Content-Range for single item', () => { + const [, , headers] = paginate(TEST_URL, makeItems(1), makePagination({ + offset: 0, + limit: 1, + total: 1 + })) + + expect(headers['Content-Range']).toBe('0-0/1') + }) + + it('omits Range headers when no exact total', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20, + approxTotal: 1000 + })) + + expect(headers['Range-Unit']).toBeUndefined() + expect(headers['Content-Range']).toBeUndefined() + }) + }) + + describe('Link header format', () => { + it('uses RFC 8288 format: ; rel="name"', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 40, + limit: 20, + total: 100 + })) + + // Check format: ; rel="name" + expect(headers.Link).toMatch(/<[^>]+>;\s*rel="[^"]+"/); + }) + + it('joins multiple links with comma', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 40, + limit: 20, + total: 100, + hasMore: true + })) + + // Should have self, first, prev, next, last + const links = headers.Link.split(',') + expect(links.length).toBeGreaterThan(1) + }) + + it('includes self link', () => { + const [, , headers] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 0, + limit: 20 + })) + + expect(headers.Link).toContain('rel="self"') + }) + }) + + describe('links in meta', () => { + it('includes links object in meta', () => { + const [body] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 40, + limit: 20, + total: 100 + })) + + expect(body.meta.links).toBeDefined() + expect(body.meta.links.self).toBeDefined() + }) + + it('includes all link relations in links object', () => { + const [body] = paginate(TEST_URL, makeItems(10), makePagination({ + offset: 40, + limit: 20, + total: 100, + hasMore: true + })) + + expect(body.meta.links.self).toBeDefined() + expect(body.meta.links.first).toBeDefined() + expect(body.meta.links.prev).toBeDefined() + expect(body.meta.links.next).toBeDefined() + expect(body.meta.links.last).toBeDefined() + }) + }) + + describe('common metadata', () => { + it('includes hasMore', () => { + const [body] = paginate(TEST_URL, makeItems(10), makePagination({ + hasMore: true + })) + + expect(body.meta.pagination.hasMore).toBe(true) + }) + + it('includes limit', () => { + const [body] = paginate(TEST_URL, makeItems(10), makePagination({ + limit: 50 + })) + + expect(body.meta.pagination.limit).toBe(50) + }) + + it('includes count', () => { + const [body] = paginate(TEST_URL, makeItems(15), makePagination({ + count: 15 + })) + + expect(body.meta.pagination.count).toBe(15) + }) + }) +}) + +// ============================================================================ +// 3. URL BUILDING +// ============================================================================ + +describe('buildUrlWithParams', () => { + it('preserves existing query params', () => { + const result = buildUrlWithParams('/search?q=test', { limit: 20 }) + + expect(result).toContain('q=test') + expect(result).toContain('limit=20') + }) + + it('replaces existing params with new values', () => { + const result = buildUrlWithParams('/search?offset=10', { offset: 30 }) + + expect(result).toBe('/search?offset=30') + }) + + it('adds new params', () => { + const result = buildUrlWithParams('/items', { offset: 0, limit: 20 }) + + expect(result).toContain('offset=0') + expect(result).toContain('limit=20') + }) + + it('handles relative paths', () => { + const result = buildUrlWithParams('/api/items', { page: 2 }) + + expect(result).toBe('/api/items?page=2') + }) + + it('handles full URLs', () => { + const result = buildUrlWithParams('https://example.com/api/items', { page: 2 }) + + expect(result).toBe('https://example.com/api/items?page=2') + }) + + it('preserves hash fragments', () => { + const result = buildUrlWithParams('/items#section', { page: 2 }) + + expect(result).toBe('/items?page=2#section') + }) + + it('skips null values', () => { + const result = buildUrlWithParams('/items', { offset: 0, cursor: null }) + + expect(result).toBe('/items?offset=0') + expect(result).not.toContain('cursor') + }) + + it('skips undefined values', () => { + const result = buildUrlWithParams('/items', { offset: 0, cursor: undefined }) + + expect(result).toBe('/items?offset=0') + }) + + it('converts boolean to "true"/"false"', () => { + const result = buildUrlWithParams('/items', { active: true, deleted: false }) + + expect(result).toContain('active=true') + expect(result).toContain('deleted=false') + }) + + it('converts numbers to strings', () => { + const result = buildUrlWithParams('/items', { page: 5, limit: 100 }) + + expect(result).toContain('page=5') + expect(result).toContain('limit=100') + }) + + it('handles empty path', () => { + const result = buildUrlWithParams('', { page: 1 }) + + expect(result).toBe('?page=1') + }) + + it('returns path unchanged when no params to add', () => { + const result = buildUrlWithParams('/items', {}) + + expect(result).toBe('/items') + }) +}) + +describe('buildCursorUrl', () => { + it('adds cursor and limit params', () => { + const result = buildCursorUrl('/items', 'cursor-abc', 20) + + expect(result).toContain('cursor=cursor-abc') + expect(result).toContain('limit=20') + }) + + it('preserves existing params', () => { + const result = buildCursorUrl('/items?filter=active', 'cursor-xyz', 50) + + expect(result).toContain('filter=active') + expect(result).toContain('cursor=cursor-xyz') + expect(result).toContain('limit=50') + }) + + it('replaces existing cursor param', () => { + const result = buildCursorUrl('/items?cursor=old', 'new-cursor', 20) + + expect(result).toContain('cursor=new-cursor') + expect(result).not.toContain('cursor=old') + }) + + it('handles base64url-safe cursor tokens', () => { + const cursor = 'eyJzb3J0RmllbGQiOiJjcmVhdGVkX2F0In0' + const result = buildCursorUrl('/items', cursor, 20) + + expect(result).toContain(`cursor=${cursor}`) + }) +}) + +describe('buildOffsetUrl', () => { + it('adds offset and limit params', () => { + const result = buildOffsetUrl('/items', 40, 20) + + expect(result).toContain('offset=40') + expect(result).toContain('limit=20') + }) + + it('preserves existing params', () => { + const result = buildOffsetUrl('/items?sort=name', 60, 30) + + expect(result).toContain('sort=name') + expect(result).toContain('offset=60') + expect(result).toContain('limit=30') + }) + + it('handles zero offset', () => { + const result = buildOffsetUrl('/items', 0, 20) + + expect(result).toContain('offset=0') + }) + + it('handles large offset', () => { + const result = buildOffsetUrl('/items', 1000000, 100) + + expect(result).toContain('offset=1000000') + }) +}) + +// ============================================================================ +// 4. RESULT HELPERS +// ============================================================================ + +describe('withHeaders', () => { + it('merges extra headers into result tuple', () => { + const original = ok({ id: '123' }) + const result = withHeaders(original, { 'X-Custom': 'value' }) + + expect(result[2]['X-Custom']).toBe('value') + }) + + it('preserves body and status', () => { + const original = ok({ id: '123' }, 201) + const result = withHeaders(original, { 'X-Custom': 'value' }) + + expect(result[0]).toEqual(original[0]) + expect(result[1]).toBe(201) + }) + + it('overwrites existing headers with same key', () => { + const original = ok({ id: '123' }) + const result = withHeaders(original, { 'Content-Type': 'text/plain' }) + + expect(result[2]['Content-Type']).toBe('text/plain') + }) + + it('preserves original Content-Type when not overwritten', () => { + const original = ok({ id: '123' }) + const result = withHeaders(original, { 'X-Custom': 'value' }) + + expect(result[2]['Content-Type']).toBe('application/json') + }) + + it('works with multiple headers', () => { + const original = ok({ id: '123' }) + const result = withHeaders(original, { + 'X-Request-Id': 'req-123', + 'X-Response-Time': '42ms', + 'Cache-Control': 'no-cache' + }) + + expect(result[2]['X-Request-Id']).toBe('req-123') + expect(result[2]['X-Response-Time']).toBe('42ms') + expect(result[2]['Cache-Control']).toBe('no-cache') + }) +}) + +describe('withMeta', () => { + it('merges extra metadata into envelope', () => { + const original = ok({ id: '123' }) + const result = withMeta(original, { custom: 'metadata' }) + + expect(result[0].meta.custom).toBe('metadata') + }) + + it('preserves existing meta including timestamp', () => { + const original = ok({ id: '123' }, 200, { existing: 'value' }) + const result = withMeta(original, { added: 'new' }) + + expect(result[0].meta.timestamp).toBeDefined() + expect(result[0].meta.existing).toBe('value') + expect(result[0].meta.added).toBe('new') + }) + + it('preserves body, status, headers', () => { + const original = ok({ id: '123' }, 201) + const result = withMeta(original, { extra: true }) + + expect(result[0].data).toEqual({ id: '123' }) + expect(result[1]).toBe(201) + expect(result[2]['Content-Type']).toBe('application/json') + }) + + it('works with pagination results', () => { + const original = paginate(TEST_URL, makeItems(10), makePagination()) + const result = withMeta(original, { query: { durationMs: 42 } }) + + expect(result[0].meta.query).toEqual({ durationMs: 42 }) + expect(result[0].meta.pagination).toBeDefined() + }) + + it('overwrites existing meta keys', () => { + const original = ok({ id: '123' }, 200, { key: 'original' }) + const result = withMeta(original, { key: 'overwritten' }) + + expect(result[0].meta.key).toBe('overwritten') + }) +}) + +describe('isSuccessResponse', () => { + it('returns true for ok() results', () => { + const result = ok({ id: '123' }) + + expect(isSuccessResponse(result)).toBe(true) + }) + + it('returns true for created() results', () => { + const result = created({ id: '123' }) + + expect(isSuccessResponse(result)).toBe(true) + }) + + it('returns true for accepted() results', () => { + const result = accepted({ taskId: 'task-123' }) + + expect(isSuccessResponse(result)).toBe(true) + }) + + it('returns true for noContent() results', () => { + const result = noContent() + + expect(isSuccessResponse(result)).toBe(true) + }) + + it('returns true for paginate() results', () => { + const result = paginate(TEST_URL, makeItems(10), makePagination()) + + expect(isSuccessResponse(result)).toBe(true) + }) + + it('returns false for error results', () => { + const result = badRequest('/test', 'Error message') + + expect(isSuccessResponse(result)).toBe(false) + }) + + it('correctly narrows type', () => { + const result = ok({ id: '123', name: 'Test' }) + + if (isSuccessResponse(result)) { + // TypeScript should know this is SuccessResponse + const [body, status] = result + expect(body.data.id).toBe('123') + expect(status).toBe(200) + } + }) +}) + +// ============================================================================ +// EDGE CASES +// ============================================================================ + +describe('edge cases', () => { + describe('empty data', () => { + it('handles empty array', () => { + const [body] = ok([]) + + expect(body.data).toEqual([]) + }) + + it('handles empty object', () => { + const [body] = ok({}) + + expect(body.data).toEqual({}) + }) + + it('paginate handles empty items array', () => { + const [body] = paginate(TEST_URL, [], makePagination({ count: 0 })) + + expect(body.data).toEqual([]) + expect(body.meta.pagination.count).toBe(0) + }) + }) + + describe('special characters in URLs', () => { + it('handles URL-encoded characters', () => { + const result = buildUrlWithParams('/search?q=hello%20world', { page: 2 }) + + expect(result).toContain('q=hello%20world') + }) + + it('handles special characters in param values', () => { + const result = buildUrlWithParams('/search', { q: 'hello world' }) + + expect(result).toContain('q=hello+world') // URLSearchParams encodes space as + + }) + }) + + describe('pagination edge cases', () => { + it('handles total of 0', () => { + const [, , headers] = paginate(TEST_URL, [], makePagination({ + offset: 0, + limit: 20, + total: 0, + count: 0 + })) + + expect(headers['X-Total-Count']).toBe('0') + expect(headers['X-Total-Pages']).toBe('1') // At least 1 page even with 0 items + }) + + it('handles limit larger than total', () => { + const [, , headers] = paginate(TEST_URL, makeItems(5), makePagination({ + offset: 0, + limit: 100, + total: 5 + })) + + expect(headers['Content-Range']).toBe('0-4/5') + }) + + it('handles very large offset', () => { + const [, , headers] = paginate(TEST_URL, [], makePagination({ + offset: 1000000, + limit: 20, + total: 100, + count: 0 + })) + + // Content-Range should handle out-of-bounds gracefully + expect(headers['Content-Range']).toBeDefined() + }) + }) + + describe('unicode in data', () => { + it('handles unicode in response data', () => { + const [body] = ok({ name: '日本語', emoji: '🚀' }) + + expect(body.data.name).toBe('日本語') + expect(body.data.emoji).toBe('🚀') + }) + + it('handles unicode in cursor values', () => { + const result = buildCursorUrl('/items', 'カーソル', 20) + + expect(result).toContain('cursor=') + }) + }) +}) \ No newline at end of file diff --git a/supabase/functions/_shared/server/create-app.ts b/supabase/functions/_shared/server/create-app.ts index 4180a58..c0452f1 100644 --- a/supabase/functions/_shared/server/create-app.ts +++ b/supabase/functions/_shared/server/create-app.ts @@ -14,13 +14,18 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status' import type { AuthUserVariables, AuthAdminVariables } from '../middleware/auth.ts' +import { AsyncLocalStorage } from 'node:async_hooks' + import { showRoutes } from 'hono/dev' import { HTTPException } from 'hono/http-exception' import { Hono } from 'hono' import { cors } from 'hono/cors' -import { logger as honoLogger } from 'hono/logger' +import { configure, getConsoleSink, getLogger as logtapeGetLogger } from '@logtape/logtape' +import { getPrettyFormatter, prettyFormatter } from "@logtape/pretty"; + +import { honoLogger } from '@logtape/hono' import { prettyJSON } from 'hono/pretty-json' import { timing, type TimingVariables } from 'hono/timing' @@ -28,7 +33,7 @@ import { requestId, type RequestIdVariables } from 'hono/request-id' import { secureHeaders, type SecureHeadersVariables } from 'hono/secure-headers' import { correlationMiddleware, getLogger, type CorrelationVariables } from '../middleware/correlation.ts' -import { err, internalServerError } from '@platform/backend/response/index.ts' +import { err, internalServerError } from '../response/mod.ts' /** * Context variables available throughout the request lifecycle. @@ -134,6 +139,46 @@ const DEFAULT_OPTIONS: CreateAppOptions = { serviceName: 'api-service', } +const formatter = getPrettyFormatter({ + // Show timestamp + timestamp: "time", // "time" | "date-time" | "date" | "rfc3339" | etc. + + // Customize icons + icons: { + info: "ℹ️", + error: "🔥" + }, + + // Control colors + colors: true, + + // Category display + categoryWidth: 20, + categoryTruncate: "middle", // "middle" | "end" | false + + // Word wrapping + wordWrap: true, // true | false | number + + // Show properties + properties: true, +}); + +await configure({ + contextLocalStorage: new AsyncLocalStorage(), + sinks: { + console: getConsoleSink({ formatter }) + }, + loggers: [ + // Keep meta visible during development/debugging: + { category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["console"] }, + + // Your app logs: + { category: ["service"], lowestLevel: "debug", sinks: ["console"] }, + ] +}); + +logtapeGetLogger(["service", "boot"]).info("LogTape configured"); + /** * Creates a configured Hono app instance for Supabase Edge Functions. * @@ -196,9 +241,14 @@ export function createApp( app.use('*', cors(corsConfig)) } - // 5. Logging + // 5. LogTape HTTP request logging (replaces hono/logger) if (config.logger) { - app.use('*', honoLogger()) + app.use('*', honoLogger({ + category: ["service", config.serviceName, 'http'], + level: 'info', + format: config.prettyJson ? 'dev' : 'combined', // dev format for pretty, combined for structured + logRequest: true, // Log at request start (default: false) + })) } // 6. Timing (for performance monitoring) @@ -240,14 +290,14 @@ export function createApp( // HTTPException with status/message but no response if (_err instanceof HTTPException) { const status = _err.status - logger.error('HTTP exception', { status, message: _err.message }) - + logger.error('HTTP exception ({status}) {message}', { status, message: _err.message }) + const [error, actualStatus, headers] = err(status, c.req.path, _err.message) return c.json(error, actualStatus, headers) } // Unexpected errors - logger.fatal('Unhandled error', { + logger.fatal('Unhandled error: {message}', { error_type: _err?.constructor?.name, message: _err instanceof Error ? _err.message : 'Unknown error', stack: _err instanceof Error ? _err.stack : undefined, diff --git a/packages/backend/src/endpoint/schemas.ts b/supabase/functions/_shared/server/schemas.ts similarity index 98% rename from packages/backend/src/endpoint/schemas.ts rename to supabase/functions/_shared/server/schemas.ts index d73d5ad..5c2dac0 100644 --- a/packages/backend/src/endpoint/schemas.ts +++ b/supabase/functions/_shared/server/schemas.ts @@ -1,6 +1,6 @@ // schemas.ts import type { FormValue, ParsedFormValue } from 'hono/types'; -import type { EndpointDefinitionSchemas } from './definitions.ts'; +import type { EndpointDefinitionSchemas } from './types.ts'; import { z } from 'zod'; /** diff --git a/supabase/functions/_shared/server/types.ts b/supabase/functions/_shared/server/types.ts index 2b56786..4183c1a 100644 --- a/supabase/functions/_shared/server/types.ts +++ b/supabase/functions/_shared/server/types.ts @@ -1,11 +1,86 @@ -import type { AppEnv as SharedAppEnv } from './create-app.ts' -import type { Handler, MiddlewareHandler, Input } from 'hono' -import type { HandlerResponse } from 'hono/types' +import type { AppEnv as SharedAppEnv } from '#shared/server/create-app.ts' +import type { Handler, MiddlewareHandler, ValidationTargets, Input } from 'hono' +import type { FormValue, HandlerResponse, ParsedFormValue } from 'hono/types' import type z from 'zod' -import type { EndpointDefinition, EndpointDefinitionSchemas } from '@platform/backend/endpoint/definitions.ts' -export type * from '@platform/backend/endpoint/definitions.ts' +/** + * Schema accepting input type I, producing any output + * + * No structural constraints - accepts: + * - Plain objects: z.object({ ... }) + * - Pipes: z.object({ ... }).pipe(...) + * - Transforms: z.object({ ... }).transform(...) + * - Unions, intersections, etc. + * + * Use this when you need input type safety but want to allow transformations. + */ +export type SchemaFor = z.ZodType +/** + * Helper to normalize record types for compatibility checking + * + * Converts Record to { [key: string]: V } to allow index signature + * schemas to satisfy specific key union requirements. + * + * This enables: z.object({}).catchall(z.string()) to satisfy + * RecordSchemaFor> + */ +type NormalizedRecordInput = + T extends Record + ? K extends string + ? { [key: string]: V } + : T + : T + +/** + * Schema with record-shaped input + * + * Accepts schemas with index signatures that can parse the expected record type. + * This allows catchall schemas to satisfy specific key union requirements. + * + * @example + * // ✓ This works - catchall accepts any string keys + * const schema = z.object({}).catchall(z.string()) + * satisfies RecordSchemaFor> + */ +export type RecordSchemaFor, Output = any> = + SchemaFor, Output> + +/** + * @deprecated Use SchemaFor instead - allows transformations + */ +export type AnySchemaFor = SchemaFor + +/** + * Validation schemas for each input source + * + * Each schema must accept the raw input type from its source: + * - Query: Record + * - Form: Record + * - Json: any + * - Param: Record + * - Header: Record + * - Cookie: Record + * + * Schemas can transform to any output type (pipes/transforms allowed). + */ +export type EndpointDefinitionSchemas = { + [K in keyof ValidationTargets as Capitalize]?: + ValidationTargets[K] extends Record + ? RecordSchemaFor[K]> + : SchemaFor[K]> +} + +// Endpoint definition contract +export type EndpointDefinition = { + Name: string + Route: string + Description?: string + Methods: readonly ('GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH')[] + Input: z.ZodType + Output: z.ZodType + Schemas: EndpointDefinitionSchemas +} /** * FunctionAppEnv with specific variable requirements. @@ -34,7 +109,13 @@ export interface EndpointHandler< _HandlerResponse extends HandlerResponse = Definition['Output'] extends z.ZodType ? HandlerResponse> : HandlerResponse > extends Handler { } -export interface EndpointMiddlewareHandler extends MiddlewareHandler { } +export interface EndpointMiddlewareHandler< + Env extends FunctionAppEnv = FunctionAppEnv, + Definition extends Partial = object, + _Route extends string = (Definition['Route'] extends string ? Definition['Route'] : string), + _Input extends Input = Definition['Schemas'] extends EndpointDefinitionSchemas ? BuildInput : Input, + _HandlerResponse extends HandlerResponse = Definition['Output'] extends z.ZodType ? HandlerResponse> : HandlerResponse +> extends MiddlewareHandler { } /** * Handler module contract for polymorphic handlers. diff --git a/packages/shared/src/types/database.types.ts b/supabase/functions/_shared/types/database.ts similarity index 100% rename from packages/shared/src/types/database.types.ts rename to supabase/functions/_shared/types/database.ts diff --git a/supabase/functions/_shared/supabase.ts b/supabase/functions/_shared/utils/clients.ts similarity index 50% rename from supabase/functions/_shared/supabase.ts rename to supabase/functions/_shared/utils/clients.ts index d1bddd1..a4c4c55 100644 --- a/supabase/functions/_shared/supabase.ts +++ b/supabase/functions/_shared/utils/clients.ts @@ -1,12 +1,13 @@ -import type { Database } from '@platform/shared/types/index.ts' +import type { Database } from '../types/database.ts' import { createClient } from '@supabase/supabase-js' -import { getSupabaseConfig } from '@platform/shared/utils/config.ts' -import { requireEnv } from '@platform/shared/utils/env.ts' +import { getSupabaseConfig } from './config.ts' +import { getEnv, requireEnv } from './env.ts' export function createAdminClient() { const config = getSupabaseConfig({ - secretKey: requireEnv('SUPABASE_SERVICE_ROLE_KEY') + publicKey: getEnv('SUPABASE_PUBLIC_KEY') || requireEnv('SUPABASE_ANON_KEY'), + secretKey: getEnv('SUPABASE_SECRET_KEY') || requireEnv('SUPABASE_SERVICE_ROLE_KEY') }); return createClient( @@ -17,7 +18,8 @@ export function createAdminClient() { export function createUserClient(authHeader: string) { const config = getSupabaseConfig({ - publicKey: requireEnv('SUPABASE_ANON_KEY') + publicKey: getEnv('SUPABASE_PUBLIC_KEY') || requireEnv('SUPABASE_ANON_KEY'), + secretKey: getEnv('SUPABASE_SECRET_KEY') || requireEnv('SUPABASE_SERVICE_ROLE_KEY') }); return createClient( diff --git a/packages/shared/src/utils/config.ts b/supabase/functions/_shared/utils/config.ts similarity index 84% rename from packages/shared/src/utils/config.ts rename to supabase/functions/_shared/utils/config.ts index c2dd0bc..92c79c7 100644 --- a/packages/shared/src/utils/config.ts +++ b/supabase/functions/_shared/utils/config.ts @@ -7,14 +7,14 @@ import { getEnv, requireEnv } from './env.ts' */ export interface SupabaseConfig { url: string - publicKey: string + publicKey?: string secretKey?: string } export function getSupabaseConfig(override?: Partial): SupabaseConfig { return { url: override?.url ?? requireEnv('SUPABASE_URL'), - publicKey: override?.publicKey ?? requireEnv('SUPABASE_PUBLIC_KEY'), + publicKey: override?.publicKey ?? getEnv('SUPABASE_PUBLIC_KEY'), secretKey: override?.secretKey ?? getEnv('SUPABASE_SECRET_KEY'), } } \ No newline at end of file diff --git a/packages/shared/src/utils/env.ts b/supabase/functions/_shared/utils/env.ts similarity index 88% rename from packages/shared/src/utils/env.ts rename to supabase/functions/_shared/utils/env.ts index c635714..ab8046c 100644 --- a/packages/shared/src/utils/env.ts +++ b/supabase/functions/_shared/utils/env.ts @@ -1,4 +1,5 @@ -// @filename: shared/src/utils/env.ts +/// + /** * Runtime detection utilities * Determines whether code is running in Deno or Node.js @@ -13,7 +14,6 @@ export const isNode = !isDeno */ export function getEnv(key: string): string | undefined { if (isDeno) { - // @ts-expect-error - Deno is available in Deno runtime return Deno.env.get(key) } return globalThis?.process?.env[key] diff --git a/supabase/functions/social/deno.json b/supabase/functions/social/deno.json index 80fca1b..ae52595 100644 --- a/supabase/functions/social/deno.json +++ b/supabase/functions/social/deno.json @@ -13,9 +13,7 @@ "hono-rate-limiter": "jsr:@hono-rate-limiter/hono-rate-limiter@0", "@hono-rate-limiter/redis": "jsr:@hono-rate-limiter/redis@0", "@hono/otel": "jsr:@hono/otel@1", - "#shared/": "../_shared/", - "@platform/shared/": "../../../packages/shared/src/", - "@platform/backend/": "../../../packages/backend/src/" + "#shared/": "../_shared/" }, "compilerOptions": { "lib": [ diff --git a/supabase/functions/social/endpoints/follows/check/definition.ts b/supabase/functions/social/endpoints/follows/check/definition.ts index b0f1576..d1dadc2 100644 --- a/supabase/functions/social/endpoints/follows/check/definition.ts +++ b/supabase/functions/social/endpoints/follows/check/definition.ts @@ -11,8 +11,8 @@ import type { EndpointDefinition } from '#shared/server/types.ts' -import { makeSuccessResponseSchema } from '@platform/backend/response/schemas.ts' -import { BaseQuerySchema } from '@platform/backend/endpoint/schemas.ts' +import { makeSuccessResponseSchema } from '#shared/response/schemas.ts' +import { BaseQuerySchema } from '#shared/server/schemas.ts' import { FollowPreferencesSchema } from '../../../utils/_schemas.ts' import { z } from 'zod' diff --git a/supabase/functions/social/endpoints/follows/check/handler.ts b/supabase/functions/social/endpoints/follows/check/handler.ts index 21a838c..79e8188 100644 --- a/supabase/functions/social/endpoints/follows/check/handler.ts +++ b/supabase/functions/social/endpoints/follows/check/handler.ts @@ -13,7 +13,7 @@ import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from import { authUserMiddleware, type AuthUserVariables } from '#shared/middleware/auth.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { ok, badRequest, internalServerError } from '@platform/backend/response/index.ts' +import { ok, badRequest, internalServerError } from '#shared/response/mod.ts' import { getLogger } from '#shared/middleware/correlation.ts' import Definition from './definition.ts' diff --git a/supabase/functions/social/endpoints/follows/follow/definition.ts b/supabase/functions/social/endpoints/follows/follow/definition.ts index f79ce0b..d877f77 100644 --- a/supabase/functions/social/endpoints/follows/follow/definition.ts +++ b/supabase/functions/social/endpoints/follows/follow/definition.ts @@ -15,8 +15,8 @@ import type { EndpointDefinition } from '#shared/server/types.ts' -import { makeSuccessResponseSchema } from '@platform/backend/response/schemas.ts' -import { BaseJsonSchema } from '@platform/backend/endpoint/schemas.ts' +import { makeSuccessResponseSchema } from '#shared/response/schemas.ts' +import { BaseJsonSchema } from '#shared/server/schemas.ts' import { FollowPreferencesSchema, FollowItemSchema } from '../../../utils/_schemas.ts' import { z } from 'zod' diff --git a/supabase/functions/social/endpoints/follows/follow/handler.ts b/supabase/functions/social/endpoints/follows/follow/handler.ts index 52549ba..940df9b 100644 --- a/supabase/functions/social/endpoints/follows/follow/handler.ts +++ b/supabase/functions/social/endpoints/follows/follow/handler.ts @@ -9,12 +9,12 @@ */ import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' -import type { Json } from '@platform/shared/types/index.ts' +import type { Json } from '#shared/types/database.ts' import { authUserMiddleware, type AuthUserVariables } from '#shared/middleware/auth.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { badRequest, conflict, created, internalServerError } from '@platform/backend/response/index.ts' +import { badRequest, conflict, created, internalServerError } from '#shared/response/mod.ts' import { getLogger } from '#shared/middleware/correlation.ts' import { diff --git a/supabase/functions/social/endpoints/follows/list/_env.ts b/supabase/functions/social/endpoints/follows/list/_env.ts index 1b8614b..3f49117 100644 --- a/supabase/functions/social/endpoints/follows/list/_env.ts +++ b/supabase/functions/social/endpoints/follows/list/_env.ts @@ -5,6 +5,6 @@ * Contains cursor secret for HMAC-signed pagination cursors */ -import { getEnv } from '@platform/shared/utils/env.ts' +import { getEnv } from '#shared/utils/env.ts' export const CURSOR_SECRET = getEnv('CURSOR_SECRET') ?? 'default-cursor-secret-change-in-production' \ No newline at end of file diff --git a/supabase/functions/social/endpoints/follows/list/definition.ts b/supabase/functions/social/endpoints/follows/list/definition.ts index e0f6676..37856ce 100644 --- a/supabase/functions/social/endpoints/follows/list/definition.ts +++ b/supabase/functions/social/endpoints/follows/list/definition.ts @@ -10,13 +10,13 @@ * Always scoped to authenticated user. */ -import type { FilterRegistry } from '@platform/backend/query/schemas.ts' +import type { FilterRegistry } from '#shared/query/schemas.ts' import type { EndpointDefinition } from '#shared/server/types.ts' -import { makePaginationResultSchema } from '@platform/backend/response/schemas.ts' -import { createEndpointQuerySchema } from '@platform/backend/query/query.ts' +import { makePaginationResultSchema } from '#shared/response/schemas.ts' +import { createEndpointQuerySchema } from '#shared/query/query.ts' -import { BaseQuerySchema } from '@platform/backend/endpoint/schemas.ts' +import { BaseQuerySchema } from '#shared/server/schemas.ts' import { FollowItemSchema } from '../../../utils/_schemas.ts' import { CURSOR_SECRET } from './_env.ts' import { z } from 'zod' diff --git a/supabase/functions/social/endpoints/follows/list/handler.ts b/supabase/functions/social/endpoints/follows/list/handler.ts index 53c148c..e955ccb 100644 --- a/supabase/functions/social/endpoints/follows/list/handler.ts +++ b/supabase/functions/social/endpoints/follows/list/handler.ts @@ -13,11 +13,11 @@ import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from import { authUserMiddleware, type AuthUserVariables } from '#shared/middleware/auth.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { paginate, internalServerError, withMeta } from '@platform/backend/response/index.ts' -import { isErrorResponse } from '@platform/backend/response/errors.ts' -import { buildPaginationMeta } from '@platform/backend/query/index.ts' +import { paginate, internalServerError, withMeta } from '#shared/response/mod.ts' +import { isErrorResponse } from '#shared/response/errors.ts' +import { buildPaginationMeta } from '#shared/query/mod.ts' -import { queryCollectionWithCount } from '#shared/query/execution/supabase.ts' +import { queryCollectionWithCount } from '#shared/execution/supabase.ts' import { getLogger } from '#shared/middleware/correlation.ts' import { CURSOR_SECRET } from './_env.ts' @@ -66,7 +66,7 @@ export const Middleware: EndpointMiddlewareHandler[] = [ * } * ``` */ -export const Handler: EndpointHandler = async function (c) { +export const Handler: EndpointHandler = async (c) => { const logger = getLogger(c) const user = c.get('user') const supabase = c.get('supabase') @@ -87,7 +87,7 @@ export const Handler: EndpointHandler = async functio // Note: RLS ensures user can only see their own follows // ======================================================================== const result = await queryCollectionWithCount({ - supabase, + supabase: supabase.schema("public"), table: 'user_follows', spec: query, countStrategy: 'exact', diff --git a/supabase/functions/social/endpoints/follows/unfollow/definition.ts b/supabase/functions/social/endpoints/follows/unfollow/definition.ts index 0acb52b..29ef073 100644 --- a/supabase/functions/social/endpoints/follows/unfollow/definition.ts +++ b/supabase/functions/social/endpoints/follows/unfollow/definition.ts @@ -14,8 +14,8 @@ import type { EndpointDefinition } from '#shared/server/types.ts' -import { makeSuccessResponseSchema } from '@platform/backend/response/schemas.ts' -import { BaseQuerySchema, BaseParamSchema } from '@platform/backend/endpoint/schemas.ts' +import { makeSuccessResponseSchema } from '#shared/response/schemas.ts' +import { BaseQuerySchema, BaseParamSchema } from '#shared/server/schemas.ts' import { z } from 'zod' // ============================================================================ diff --git a/supabase/functions/social/endpoints/follows/unfollow/handler.ts b/supabase/functions/social/endpoints/follows/unfollow/handler.ts index 25582e9..4bb2f60 100644 --- a/supabase/functions/social/endpoints/follows/unfollow/handler.ts +++ b/supabase/functions/social/endpoints/follows/unfollow/handler.ts @@ -13,7 +13,7 @@ import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from import { authUserMiddleware, type AuthUserVariables } from '#shared/middleware/auth.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { ok, notFound, forbidden, internalServerError } from '@platform/backend/response/index.ts' +import { ok, notFound, forbidden, internalServerError } from '#shared/response/mod.ts' import { getLogger } from '#shared/middleware/correlation.ts' import Definition from './definition.ts' diff --git a/supabase/functions/social/endpoints/follows/update/definition.ts b/supabase/functions/social/endpoints/follows/update/definition.ts index e64b66a..34cea86 100644 --- a/supabase/functions/social/endpoints/follows/update/definition.ts +++ b/supabase/functions/social/endpoints/follows/update/definition.ts @@ -13,8 +13,8 @@ import type { EndpointDefinition } from '#shared/server/types.ts' -import { makeSuccessResponseSchema } from '@platform/backend/response/schemas.ts' -import { BaseJsonSchema, BaseParamSchema } from '@platform/backend/endpoint/schemas.ts' +import { makeSuccessResponseSchema } from '#shared/response/schemas.ts' +import { BaseJsonSchema, BaseParamSchema } from '#shared/server/schemas.ts' import { FollowPreferencesSchema, FollowItemSchema } from '../../../utils/_schemas.ts' import { z } from 'zod' diff --git a/supabase/functions/social/endpoints/follows/update/handler.ts b/supabase/functions/social/endpoints/follows/update/handler.ts index 99f6d17..e765675 100644 --- a/supabase/functions/social/endpoints/follows/update/handler.ts +++ b/supabase/functions/social/endpoints/follows/update/handler.ts @@ -10,12 +10,12 @@ import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' import type { FollowPreferences } from '../../../utils/_schemas.ts' -import type { Json } from '@platform/shared/types/index.ts' +import type { Json } from '#shared/types/database.ts' import { authUserMiddleware, type AuthUserVariables } from '#shared/middleware/auth.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { ok, notFound, forbidden, internalServerError } from '@platform/backend/response/index.ts' +import { ok, notFound, forbidden, internalServerError } from '#shared/response/mod.ts' import { getLogger } from '#shared/middleware/correlation.ts' import { deepMergePreferences } from '../../../utils/_schemas.ts' @@ -61,7 +61,7 @@ export const Middleware: EndpointMiddlewareHandler[] = [ * @throws 404 if follow not found * @throws 500 if database error */ -export const Handler: EndpointHandler = async function (c) { +export const Handler: EndpointHandler = async (c) => { const logger = getLogger(c) const user = c.get('user') const supabase = c.get('supabase') diff --git a/supabase/functions/social/endpoints/likes/check/definition.ts b/supabase/functions/social/endpoints/likes/check/definition.ts index 78586a2..4906df3 100644 --- a/supabase/functions/social/endpoints/likes/check/definition.ts +++ b/supabase/functions/social/endpoints/likes/check/definition.ts @@ -1,7 +1,7 @@ // endpoint/likes/check/definition.ts import type { EndpointDefinition } from '#shared/server/types.ts' -import { BaseQuerySchema } from '@platform/backend/endpoint/schemas.ts' +import { BaseQuerySchema } from '#shared/server/schemas.ts' import { z } from 'zod' export const QuerySchema = BaseQuerySchema.extend({ diff --git a/supabase/functions/social/endpoints/likes/check/handler.ts b/supabase/functions/social/endpoints/likes/check/handler.ts index b56e453..7681329 100644 --- a/supabase/functions/social/endpoints/likes/check/handler.ts +++ b/supabase/functions/social/endpoints/likes/check/handler.ts @@ -4,7 +4,7 @@ import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from import { authUserMiddleware, type AuthUserVariables } from '#shared/middleware/auth.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { ok, internalServerError } from '@platform/backend/response/index.ts' +import { ok, internalServerError } from '#shared/response/mod.ts' import { getLogger } from '#shared/middleware/correlation.ts' import Definition from './definition.ts' diff --git a/supabase/functions/social/endpoints/likes/like/definition.ts b/supabase/functions/social/endpoints/likes/like/definition.ts index dac1346..c59ffe8 100644 --- a/supabase/functions/social/endpoints/likes/like/definition.ts +++ b/supabase/functions/social/endpoints/likes/like/definition.ts @@ -1,8 +1,8 @@ // endpoint/likes/like/definition.ts import type { EndpointDefinition } from '#shared/server/types.ts' -import { makeSuccessResponseSchema } from '@platform/backend/response/schemas.ts' -import { BaseJsonSchema } from '@platform/backend/endpoint/schemas.ts' +import { makeSuccessResponseSchema } from '#shared/response/schemas.ts' +import { BaseJsonSchema } from '#shared/server/schemas.ts' import { z } from 'zod' export const JsonSchema = z.object({ diff --git a/supabase/functions/social/endpoints/likes/like/handler.ts b/supabase/functions/social/endpoints/likes/like/handler.ts index 18b1e4f..298e3cb 100644 --- a/supabase/functions/social/endpoints/likes/like/handler.ts +++ b/supabase/functions/social/endpoints/likes/like/handler.ts @@ -4,7 +4,7 @@ import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from import { authUserMiddleware, type AuthUserVariables } from '#shared/middleware/auth.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { badRequest, conflict, created, internalServerError } from '@platform/backend/response/index.ts' +import { badRequest, conflict, created, internalServerError } from '#shared/response/mod.ts' import { getLogger } from '#shared/middleware/correlation.ts' import Definition from './definition.ts' diff --git a/supabase/functions/social/endpoints/likes/list/_env.ts b/supabase/functions/social/endpoints/likes/list/_env.ts index 2da0af3..421e444 100644 --- a/supabase/functions/social/endpoints/likes/list/_env.ts +++ b/supabase/functions/social/endpoints/likes/list/_env.ts @@ -1,3 +1,3 @@ -import { getEnv } from '@platform/shared/utils/env.ts' +import { getEnv } from '#shared/utils/env.ts' export const CURSOR_SECRET = getEnv('CURSOR_SECRET') ?? 'default-secret-change-in-production' \ No newline at end of file diff --git a/supabase/functions/social/endpoints/likes/list/definition.ts b/supabase/functions/social/endpoints/likes/list/definition.ts index e7d5530..e9b7926 100644 --- a/supabase/functions/social/endpoints/likes/list/definition.ts +++ b/supabase/functions/social/endpoints/likes/list/definition.ts @@ -1,11 +1,11 @@ // endpoint/likes/list/definition.ts -import type { FilterRegistry } from '@platform/backend/query/schemas.ts' +import type { FilterRegistry } from '#shared/query/schemas.ts' import type { EndpointDefinition } from '#shared/server/types.ts' -import { makePaginationResultSchema } from '@platform/backend/response/schemas.ts' -import { createEndpointQuerySchema } from '@platform/backend/query/query.ts' +import { makePaginationResultSchema } from '#shared/response/schemas.ts' +import { createEndpointQuerySchema } from '#shared/query/query.ts' -import { BaseQuerySchema } from '@platform/backend/endpoint/schemas.ts' +import { BaseQuerySchema } from '#shared/server/schemas.ts' import { CURSOR_SECRET } from './_env.ts' import { z } from 'zod' diff --git a/supabase/functions/social/endpoints/likes/list/handler.ts b/supabase/functions/social/endpoints/likes/list/handler.ts index 6b8fdb9..d16f48a 100644 --- a/supabase/functions/social/endpoints/likes/list/handler.ts +++ b/supabase/functions/social/endpoints/likes/list/handler.ts @@ -4,12 +4,12 @@ import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from import { authUserMiddleware, type AuthUserVariables } from '#shared/middleware/auth.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { paginate, internalServerError, withMeta } from '@platform/backend/response/index.ts' +import { paginate, internalServerError, withMeta } from '#shared/response/mod.ts' import { getLogger } from '#shared/middleware/correlation.ts' -import { buildPaginationMeta } from '@platform/backend/query/index.ts' +import { buildPaginationMeta } from '#shared/query/mod.ts' -import { queryCollectionWithCount } from '#shared/query/execution/supabase.ts' -import { isErrorResponse } from '@platform/backend/response/errors.ts' +import { queryCollectionWithCount } from '#shared/execution/supabase.ts' +import { isErrorResponse } from '#shared/response/errors.ts' import { CURSOR_SECRET } from './_env.ts' import Definition from './definition.ts' @@ -94,7 +94,7 @@ export const Middleware: EndpointMiddlewareHandler[] = [ * @throws 410 if cursor has expired * @throws 500 if database error */ -export const Handler: EndpointHandler = async function (c) { +export const Handler: EndpointHandler = async (c) => { const logger = getLogger(c) const user = c.get('user') const supabase = c.get('supabase') @@ -114,7 +114,7 @@ export const Handler: EndpointHandler = async functio // Execute query with base filters for RLS const result = await queryCollectionWithCount({ - supabase, + supabase: supabase.schema("public"), table: 'likes', spec: parsed, countStrategy: 'estimated', diff --git a/supabase/functions/social/endpoints/likes/unlike/definition.ts b/supabase/functions/social/endpoints/likes/unlike/definition.ts index 61a5f22..d901a95 100644 --- a/supabase/functions/social/endpoints/likes/unlike/definition.ts +++ b/supabase/functions/social/endpoints/likes/unlike/definition.ts @@ -1,7 +1,7 @@ // endpoint/likes/unlike/definition.ts import { z } from 'zod' import type { EndpointDefinition } from '#shared/server/types.ts' -import { BaseQuerySchema, BaseParamSchema } from '@platform/backend/endpoint/schemas.ts' +import { BaseQuerySchema, BaseParamSchema } from '#shared/server/schemas.ts' export const Route = '/likes/:target_id'; export const ParamSchema = BaseParamSchema.extend({ diff --git a/supabase/functions/social/endpoints/likes/unlike/handler.ts b/supabase/functions/social/endpoints/likes/unlike/handler.ts index b2800ae..f6585c6 100644 --- a/supabase/functions/social/endpoints/likes/unlike/handler.ts +++ b/supabase/functions/social/endpoints/likes/unlike/handler.ts @@ -3,7 +3,7 @@ import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from import { authUserMiddleware, type AuthUserVariables } from '#shared/middleware/auth.ts' import { createValidator } from '#shared/middleware/validation.ts' -import { ok, notFound, internalServerError } from '@platform/backend/response/index.ts' +import { ok, notFound, internalServerError } from '#shared/response/mod.ts' import { getLogger } from '#shared/middleware/correlation.ts' import Definition from './definition.ts'