diff --git a/edge/_shared/execution/mod.ts b/edge/_shared/execution/mod.ts deleted file mode 100644 index 4cced0e..0000000 --- a/edge/_shared/execution/mod.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './supabase.ts' -export * as sparql from './sparql.ts' \ No newline at end of file diff --git a/edge/_shared/execution/sparql.ts b/edge/_shared/execution/sparql.ts deleted file mode 100644 index e54d9a2..0000000 --- a/edge/_shared/execution/sparql.ts +++ /dev/null @@ -1,544 +0,0 @@ -/** - * 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/edge/_shared/execution/supabase.ts b/edge/_shared/execution/supabase.ts deleted file mode 100644 index 09e185d..0000000 --- a/edge/_shared/execution/supabase.ts +++ /dev/null @@ -1,820 +0,0 @@ -// utils/query/execution/supabase.ts -/** - * Query execution utilities with proper error handling - * - * Features: - * - Type-safe Supabase query execution - * - Proper error discrimination (cursor expiry, invalid cursor, database errors) - * - Optional count strategies (exact, planned, estimated) - * - Base filter support for security/scoping - */ - -import type { BaseFiltersNormalized, CursorData, FieldSelectionNormalized, FiltersNormalized, PaginationNormalized, SortsNormalized } from '../query/schemas.ts' -import type { SupabaseClient } from '@supabase/supabase-js' - -import type { Database } from '../types/database.ts' -import type { QuerySpec } from '../query/schemas.ts' - -import { ok } from '../response/success.ts' -import { badRequest, exception, internalServerError } from '../response/errors.ts' - -// ============================================================================ -// EXECUTION OPTIONS -// ============================================================================ - -/** - * Options for collection query execution - */ -export interface CollectionQueryOptions { - /** - * Whether to include total count in result - * @default false - */ - includeCount?: boolean - - /** - * Count strategy when includeCount is true - * - 'exact': Full COUNT(*) - slow for large tables - * - 'planned': Postgres planner estimate - fast but approximate - * - 'estimated': Table statistics - instant but can be very approximate - * @default 'exact' - */ - countStrategy?: 'exact' | 'planned' | 'estimated' - - /** - * Base filters to apply before QuerySpec (for security/scoping) - * These are applied first, before any user-provided filters - * - * @example - * baseFilters: [ - * { field: 'tenant_id', value: currentUser.tenantId }, - * { field: 'deleted_at', operator: 'is', value: null } - * ] - */ - 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) -// ============================================================================ - -/** - * Build Supabase SELECT clause from field selection - * - * Converts normalized field selection into a comma-separated string - * suitable for Supabase's .select() method. - * - * @param selection Validated field selection (or null for all fields) - * @param resourceType Optional resource type for JSON:API selection - * @returns SELECT clause string (e.g., 'id,name,price' or '*') - * - * @example - * // Simple selection - * const select = buildSelectClause({ - * type: 'simple', - * fields: ['id', 'name', 'price'] - * }) - * // => 'id,name,price' - * - * // JSON:API selection - * const select = buildSelectClause({ - * type: 'jsonapi', - * fields: { - * products: ['id', 'name'], - * categories: ['name'] - * } - * }, 'products') - * // => 'id,name' - * - * // No selection (return all fields) - * const select = buildSelectClause(null) - * // => '*' - * - * // Usage with Supabase - * const query = supabase - * .from('products') - * .select(buildSelectClause(fieldSelection)) - */ -export function buildSelectClause( - selection: FieldSelectionNormalized | null, - resourceType?: string -): string { - if (!selection) return '*' - if (selection.type === 'simple') { - return selection.fields.join(',') - } - - // JSON:API format - get fields for specific resource type - const fields = resourceType - ? selection.fields[resourceType] - : Object.values(selection.fields)[0] - - return fields?.join(',') ?? '*' -} - -/** - * Apply sorts to Supabase query - * Tiebreaker is added during validation, not here - * - * @param query Supabase query builder - * @param sorts Validated and normalized sorts array - * @returns Query builder with sorts applied - * - * @example - * let query = supabase.from('posts').select('*') - * query = applySorts(query, [ - * { field: 'created_at', direction: 'desc' }, - * { field: 'id', direction: 'asc' } - * ]) - */ -export function applySorts< - TSchema extends SchemaName, ->( - query: SupabaseClientSelectType, - sorts: SortsNormalized | null -) { - // No-op when sorting disabled - if (!sorts || sorts.length === 0) { - return query - } - - let result = query; - - for (const sort of sorts) { - result = result.order(sort.field, { - ascending: sort.direction === 'asc' - }) - } - - return result -} - -/** - * Apply filters to Supabase query - * - * @param query Supabase query builder - * @param filters Validated and normalized filters array - * @returns Query builder with filters applied - * - * @example - * let query = supabase.from('products').select('*') - * query = applyFilters(query, [ - * { field: 'category', operator: 'eq', value: 'electronics' }, - * { field: 'price', operator: 'gte', value: 50 } - * ]) - */ -export function applyFilters< - TSchema extends SchemaName, ->( - query: SupabaseClientSelectType, - filters: FiltersNormalized | null -) { - // No-op when filtering disabled - if (!filters || filters.length === 0) return query - - let result = query - - 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': - result = result.gt(filter.field, filter.value) - break - case 'gte': - result = result.gte(filter.field, filter.value) - break - case 'lt': - result = result.lt(filter.field, filter.value) - break - case 'lte': - result = result.lte(filter.field, filter.value) - break - case 'between': { - // Value is guaranteed to be [min, max] from validation - const [min, max] = filter.value as [number | string, number | string] - result = result.gte(filter.field, min).lte(filter.field, max) - break - } - case 'in': { - const values = Array.isArray(filter.value) - ? filter.value - : String(filter.value).split(',').map(v => v.trim()) - result = result.in(filter.field, values) - break - } - case 'nin': { - const values = Array.isArray(filter.value) - ? filter.value - : String(filter.value).split(',').map(v => v.trim()) - result = result.not(filter.field, 'in', values) - break - } - case 'contains': - result = result.like(filter.field, `%${filter.value}%`) // case-sensitive - break - case 'icontains': - result = result.ilike(filter.field, `%${filter.value}%`) // case-insensitive - break - case 'startswith': - result = result.like(filter.field, `${filter.value}%`) - break - case 'endswith': - result = result.like(filter.field, `%${filter.value}`) - break - case 'is_null': - result = result.is(filter.field, null) - break - case 'is_not_null': - result = result.not(filter.field, 'is', null) - break - default: { - const _exhaustive: never = filter.operator; - throw exception(badRequest(JSON.stringify(filter), `Unknown operator: ${filter.operator}`)) - } - } - } - - return result -} - -/** - * Apply **cursor pagination** (a.k.a. “keyset pagination”) to a PostgREST/Supabase-style query. - * - * # What this does (in one sentence) - * It adds a **WHERE** filter that selects rows **strictly before/after** a known row (the “cursor”), - * using a primary sort column + a **tiebreaker** column, and sets the page size to `limit + 1` so - * we can detect `hasMore` without extra COUNTs. - * - * --- - * - * # Why two columns? - * Many rows can share the same `sortField` (e.g., many items have the same `created_at` second). - * To make ordering **strict** and deterministic, we combine: - * - **Primary sort**: `sortField` (e.g., `created_at`) - * - **Tiebreaker**: `tiebreaker` (e.g., `id` that is unique and monotonically increasing) - * - * This pair defines a **total order** so we can say “fetch rows *after* (or *before*) this cursor.” - * - * --- - * - * # The core predicate (human explanation) - * - * Given a decoded cursor `{ sortField, sortValue, tiebreaker, tiebreakerValue, direction }`: - * - * - If `direction === "desc"` (newest first), “next page” means **older** than the cursor: - * - * ``` - * (sortField < sortValue) - * OR - * (sortField = sortValue AND tiebreaker < tiebreakerValue) - * ``` - * - * - If `direction === "asc"` (oldest first), “next page” means **newer** than the cursor: - * - * ``` - * (sortField > sortValue) - * OR - * (sortField = sortValue AND tiebreaker > tiebreakerValue) - * ``` - * - * That’s the entire trick. The rest is just building this predicate in your query builder. - * - * --- - * - * # How the `.or(...)` string is built (Supabase/PostgREST) - * - * PostgREST encodes boolean logic in a string. Commas mean **OR**, `and(...)` groups an **AND** block. - * Comparators are suffixes on the column: `.lt.`, `.gt.`, `.eq.` etc. - * - * For the **descending** case, we build: - * - * ```txt - * ${sortField}.lt.${sortValue}, - * and(${sortField}.eq.${sortValue},${tiebreaker}.lt.${tiebreakerValue}) - * ``` - * - * Read aloud: “`sortField < sortValue` **OR** (`sortField = sortValue` **AND** `tiebreaker < tiebreakerValue`).” - * - * For the **ascending** case, we swap `.lt.` for `.gt.`: - * - * ```txt - * ${sortField}.gt.${sortValue}, - * and(${sortField}.eq.${sortValue},${tiebreaker}.gt.${tiebreakerValue}) - * ``` - * - * **Important:** Your client library will URL-encode values. If your values contain commas/parentheses, - * always pass raw values and let the client encode them—don’t pre-encode by hand. - * - * --- - * - * # Limit + 1 (probe row) and `hasMore` - * - * We always request **`limit + 1`** rows. After fetching: - * - If we received **`limit + 1`** → there **is** another page (`hasMore = true`). We drop the last row (the probe). - * - Otherwise → this is the **last** page (`hasMore = false`). - * - * This avoids `COUNT(*)` and keeps paging snappy and cheap. - * - * --- - * - * # Pre-conditions (what you must also do) - * - * - Apply a **matching ORDER BY**: - * - For `desc` pages: `ORDER BY sortField DESC, tiebreaker DESC` - * - For `asc` pages: `ORDER BY sortField ASC, tiebreaker ASC` - * - Ensure a **composite index** on `(sortField, tiebreaker)` for performance. - * - Avoid `NULL`s in `sortField`/`tiebreaker`, or normalize them (e.g., `COALESCE`) so ordering is well-defined. - * - Make sure the `tiebreaker` column is **unique** or at least strictly ordered to break ties. - * - * --- - * - * # Parameters - * @param query - * A Supabase/PostgREST-style query builder (anything supporting `.or(string)` and `.limit(n)`). - * - * @param cursor - * Decoded cursor describing the boundary row and the paging direction. - * - `sortField`: primary sort column name (e.g., `"created_at"`) - * - `sortValue`: the value from the cursor row (e.g., `"2025-11-10T20:15:36.123Z"`) - * - `tiebreaker`: secondary column for strict ordering (e.g., `"id"`) - * - `tiebreakerValue`: value from the cursor row (e.g., `"9f1c..."` - * - `direction`: `"asc"` or `"desc"` — must match your `ORDER BY` - * - * @param limit - * Page size (we internally request `limit + 1` to probe for `hasMore`). - * - * @returns - * The same query builder with an added `.or(...)` filter and `.limit(limit + 1)`. - * - * --- - * - * # Examples - * - * ## Example 1 — Descending (newest first) - * We’re on `/posts?limit=20&cursor=...` and want the **next** page (older posts). - * - * Cursor row: - * - `sortField = "created_at"` - * - `sortValue = "2025-11-10T20:15:36.123Z"` - * - `tiebreaker = "id"` - * - `tiebreakerValue = "6b2a2f..."` (a ULID/UUID) - * - `direction = "desc"` - * - * The filter we add is: - * - * ```txt - * created_at.lt.2025-11-10T20:15:36.123Z, - * and(created_at.eq.2025-11-10T20:15:36.123Z,id.lt.6b2a2f...) - * ``` - * - * Read as: “rows **older** than the cursor OR same timestamp but smaller id.” - * - * We also call `.limit(21)`. After fetching 21 rows, we return 20 and set `hasMore = true` if row 21 exists. - * - * ## Example 2 — Ascending (oldest first) - * Same columns, but `direction = "asc"` (we want **newer** rows). - * - * The filter becomes: - * - * ```txt - * created_at.gt.2025-11-10T20:15:36.123Z, - * and(created_at.eq.2025-11-10T20:15:36.123Z,id.gt.6b2a2f...) - * ``` - * - * Read as: “rows **newer** than the cursor OR same timestamp but larger id.” - * - * ## Example 3 — Different columns - * Sorting by a score and breaking ties by a numeric `serial`: - * - * - Descending: `score.lt.98,and(score.eq.98,serial.lt.4312)` - * - Ascending: `score.gt.98,and(score.eq.98,serial.gt.4312)` - * - * ## Example 4 — Putting it all together - * ```ts - * // 1) ensure ORDER BY matches cursor direction - * const ordered = db - * .from('posts') - * .select('*') - * .order('created_at', { ascending: false }) // direction === 'desc' - * .order('id', { ascending: false }) - * - * // 2) apply cursor predicate + limit+1 - * const pageQ = applyCursorPagination(ordered, { - * sortField: 'created_at', - * sortValue: '2025-11-10T20:15:36.123Z', - * tiebreaker: 'id', - * tiebreakerValue: '6b2a2f...', - * direction: 'desc' - * }, 20) - * - * // 3) execute and then slice(0, limit) to compute hasMore - * const rows = await pageQ - * const items = rows.slice(0, 20) - * const hasMore = rows.length > 20 - * ``` - */ -export function applyCursorPagination< - TSchema extends SchemaName, ->( - query: SupabaseClientSelectType, - cursor: CursorData, - limit: number -) { - const { sortField, sortValue, tiebreaker, tiebreakerValue, direction } = cursor - - /** - * Branch 1: The "primary sort field" comparison. - * - * If direction is "desc", we want rows where sortField < sortValue. - * Example: Suppose sortField = "created_at", sortValue = "2025-01-01". - * Then branch1 = "created_at.lt.2025-01-01" - * → This matches rows created *before* Jan 1, 2025. - * - * If direction is "asc", we want rows where sortField > sortValue. - * Example: sortField = "created_at", sortValue = "2025-01-01". - * Then branch1 = "created_at.gt.2025-01-01" - * → This matches rows created *after* Jan 1, 2025. - * - * This branch handles the "normal" case where the primary sort field alone - * determines whether a row comes after the cursor. - */ - const branch1 = - direction === "desc" - ? `${sortField}.lt.${sortValue}` - : `${sortField}.gt.${sortValue}` - - /** - * Branch 2: The "tie-breaker" comparison. - * - * If the primary sort field equals the cursor value, we need a secondary - * comparison to break ties. This ensures stable ordering. - * - * For descending order: - * Example: sortField = "created_at", sortValue = "2025-01-01" - * tiebreaker = "id", tiebreakerValue = 100 - * Then branch2 = "and(created_at.eq.2025-01-01,id.lt.100)" - * → This matches rows created exactly on Jan 1, 2025, - * but with an id less than 100. - * - * For ascending order: - * Example: sortField = "created_at", sortValue = "2025-01-01" - * tiebreaker = "id", tiebreakerValue = 100 - * Then branch2 = "and(created_at.eq.2025-01-01,id.gt.100)" - * → This matches rows created exactly on Jan 1, 2025, - * but with an id greater than 100. - * - * This branch ensures that when multiple rows share the same sortField value, - * we can still paginate deterministically. - */ - const branch2 = - direction === "desc" - ? `and(${sortField}.eq.${sortValue},${tiebreaker}.lt.${tiebreakerValue})` - : `and(${sortField}.eq.${sortValue},${tiebreaker}.gt.${tiebreakerValue})` - - /** - * Combine Branch 1 and Branch 2 with OR logic. - * - * The final predicate says: - * "Either the row’s sortField is beyond the cursor (branch1), - * OR the row’s sortField equals the cursor but the tiebreaker is beyond it (branch2)." - * - * Example (descending): - * predicate = "created_at.lt.2025-01-01, - * and(created_at.eq.2025-01-01,id.lt.100)" - * - * This matches: - * - Any row created before Jan 1, 2025 - * - OR rows created exactly on Jan 1, 2025 with id < 100 - * - * Together, this implements cursor pagination: we only fetch rows that come - * "after" the cursor in the sorted order. - */ - return query.or(`${branch1},${branch2}`).limit(limit + 1) -} - -/** - * Apply offset pagination to Supabase query - */ -export function applyOffsetPagination< - TSchema extends SchemaName, ->( - query: SupabaseClientSelectType, - offset: number, - limit: number -) { - return query.range(offset, offset + limit - 1) -} - -/** - * Apply pagination (auto-detects type) - */ -export function applyPagination< - TSchema extends SchemaName, ->( - query: SupabaseClientSelectType, - params: PaginationNormalized -) { - if (params.type === 'cursor') { - if (params.decodedCursor) { - return applyCursorPagination(query, params.decodedCursor, params.limit) - } else { - return query.limit(params.limit + 1) - } - } else { - return applyOffsetPagination(query, params.offset, params.limit) - } -} - -/** - * Apply complete QuerySpec to Supabase query - * - * This applies all aspects of a validated QuerySpec to a Supabase query builder - * in the correct order: - * 1. Filters (WHERE clauses) - skipped if null - * 2. Sorts (ORDER BY clauses) - skipped if null - * 3. Pagination (LIMIT/OFFSET or cursor-based) - always applied - * - * NEW: Gracefully handles null values for disabled components - * - * @param query Supabase query builder instance - * @param spec Validated QuerySpec - * @returns Modified query builder (chainable) - * - * @example - * // All features enabled - * let query = supabase.from('posts').select('*') - * query = applyQuerySpec(query, spec) - * - * @example - * // Some features disabled (null in spec) - * const spec = { - * pagination: { type: 'offset', offset: 0, limit: 20 }, - * filters: null, // Filtering disabled - * sorts: null, // Sorting disabled - * fields: null // Field selection disabled - * } - * let query = supabase.from('posts').select('*') - * query = applyQuerySpec(query, spec) - * // Only pagination is applied - */ -export function applyQuerySpec< - TSchema extends SchemaName, ->( - query: SupabaseClientSelectType, - spec: QuerySpec -) { - // Apply filters only if enabled - if (spec.filters !== null) { - query = applyFilters(query, spec.filters) - } - - // Apply sorts only if enabled - if (spec.sorts !== null) { - query = applySorts(query, spec.sorts) - } - - // Always apply pagination (required) - query = applyPagination(query, spec.pagination) - - return query -} - -/** - * Execute a paginated collection query with proper error handling - * - * This is the main entry point for executing database queries with - * pagination, filtering, sorting, and field selection. It handles - * all error cases properly including cursor expiry. - * - * @param supabase Supabase client instance - * @param table Table name from database schema - * @param spec Validated QuerySpec from schema parsing - * @param options Optional configuration for count and base filters - * @returns Result with success/error discrimination - * - * @example - * // Basic usage - * const result = await executeCollectionQuery( - * supabase, - * 'posts', - * querySpec - * ) - * - * if (result.success) { - * return c.json(...paginate(c.req.url, result.data, paginationMeta)) - * } - * - * // Handle errors - * switch (result.error) { - * case 'cursor_expired': - * return c.json(...gone(c.req.path, result.message)) - * case 'cursor_invalid': - * return c.json(...badRequest(c.req.path, result.message)) - * case 'database': - * return c.json(...internalServerError(c.req.path, result.message)) - * } - * - * @example - * // With count and base filters - * const result = await executeCollectionQuery( - * supabase, - * 'posts', - * querySpec, - * { - * includeCount: true, - * countStrategy: 'estimated', - * baseFilters: [ - * { field: 'tenant_id', value: user.tenantId }, - * { field: 'deleted_at', operator: 'is', value: null } - * ] - * } - * ) - */ -export async function executeCollectionQuery< - TSchema extends SchemaName, - TRelation extends RelationName, ->( - supabase: RestClient, - relation: TRelation, - spec: QuerySpec, - options?: CollectionQueryOptions, -) { - const { - includeCount = false, - countStrategy = 'exact', - baseFilters, - } = options ?? {} - - // Build SELECT clause from field selection - const selectClause = buildSelectClause(spec.fields) - - // Create base query with optional count - let query = supabase.from(relation).select(selectClause, { - count: includeCount ? countStrategy : undefined, - head: false, - }) as SupabaseClientSelectType - - // Apply base filters first (security/scoping) - if (baseFilters && baseFilters.length > 0) { - const filters = baseFilters.map(filter => - Object.assign({ operator: filter.operator ?? 'eq' }, filter) - ); - - // Apply base filters - query = applyFilters(query, filters) - } - - // Apply QuerySpec (filters, sorts, pagination) - query = applyQuerySpec(query, spec) - - // Execute query - const result = await query - - // Handle Supabase errors - if (result.error) { - return internalServerError("database", result.error.message) - } - - // Success case - return ok( - (result.data as RelationRow[]) ?? [], - 200, - { total: result.count ?? null } - ) -} - -// ============================================================================ -// CONVENIENCE WRAPPERS -// ============================================================================ - -/** - * Execute collection query without count (most common case) - * - * @example - * const result = await queryCollection(supabase, 'posts', querySpec) - */ -export function queryCollection< - TSchema extends SchemaName, - TRelation extends RelationName, ->( - supabase: RestClient, - relation: TRelation, - spec: QuerySpec, - baseFilters?: CollectionQueryOptions['baseFilters'] -) { - return executeCollectionQuery(supabase, relation, spec, { - includeCount: false, - baseFilters - }) -} - -/** - * Execute collection query with count - * - * @example - * const result = await queryCollectionWithCount({ - * supabase, - * table: 'posts', - * spec: querySpec, - * countStrategy: 'estimated' // fast approximate count - * }) - */ -export function queryCollectionWithCount< - TSchema extends SchemaName, - TRelation extends RelationName, ->(opts: { - supabase: RestClient, - table: TRelation, - spec: QuerySpec, - countStrategy: 'exact' | 'planned' | 'estimated', - baseFilters?: CollectionQueryOptions['baseFilters'] -}) { - const { supabase, table, spec, countStrategy = 'exact', baseFilters } = opts; - return executeCollectionQuery(supabase, table, spec, { - includeCount: true, - countStrategy, - baseFilters - }) -} \ No newline at end of file diff --git a/edge/_shared/middleware/auth.ts b/edge/_shared/middleware/auth.ts deleted file mode 100644 index f63ad1e..0000000 --- a/edge/_shared/middleware/auth.ts +++ /dev/null @@ -1,61 +0,0 @@ - -// middleware/auth.ts -import type { Context, MiddlewareHandler, Next } from 'hono' -import type { User } from '@supabase/supabase-js' - -import { createAdminClient, createUserClient } from '../utils/clients.ts' -import { unauthorized } from '../response/mod.ts' -import { getLogger } from './correlation.ts' - -export type AuthUserVariables = { - /** Authenticated user ID from JWT */ - user: User; - /** Authenticated Supabase client with user context from Authorization header */ - supabase: ReturnType; -} - -export type AuthAdminVariables = { - /** Admin Supabase client with service role (bypasses RLS) */ - admin?: ReturnType; -} - -export const authUserMiddleware: MiddlewareHandler = async (c: Context, next: Next) => { - const logger = getLogger(c) - const authHeader = c.req.header('Authorization') - - if (!authHeader?.startsWith('Bearer ')) { - logger.warn('Missing authorization header', { - path: c.req.path, - method: c.req.method, - }) - - return c.json(...unauthorized(c.req.path, 'Missing or invalid authorization header')); - } - - const supabase = createUserClient(authHeader); - const { data: { user }, error: userError } = await supabase.auth.getUser(); - - if (userError || !user) { - logger.warn('Authentication failed', { - user_error: userError?.message, - }) - - return c.json(...unauthorized(c.req.path, 'Authentication required')); - } - - c.set('user', user); - c.set('supabase', supabase); - - logger.debug('User authenticated', { - user_id: user.id, - }) - - await next() -} - -export const authAdminMiddleware: MiddlewareHandler = async (c: Context, next: Next) => { - const supabase = createAdminClient(); - c.set('admin', supabase) - await next() -} - diff --git a/edge/_shared/middleware/correlation.ts b/edge/_shared/middleware/correlation.ts deleted file mode 100644 index 1d04f07..0000000 --- a/edge/_shared/middleware/correlation.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Request Correlation & Distributed Tracing for Hono + LogTape - * - * 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. - * - * 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 -} - -/** - * Variables attached to Hono context by correlation middleware. - * Use type augmentation to add these to your app's Variables interface. - */ -export interface CorrelationVariables { - /** Extracted or generated correlation context */ - correlation: RequestCorrelation - - /** 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 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). - * - * 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 - * - * 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')) - * - * // 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) - - // 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( - 'traceparent', - `00-${correlation.traceId}-${correlation.spanId}-01` - ) - if (correlation.parentSpanId) { - traceHeaders.set('tracestate', `parent=${correlation.parentSpanId}`) - } - - // Attach to Hono context for handler access - c.set('correlation', correlation) - c.set('traceHeaders', traceHeaders) - c.set('serviceName', serviceName) - - - // Set response header so clients can correlate their requests with our logs - c.header('X-Request-ID', correlation.requestId) - - // 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. - * - * Follows W3C Trace Context specification for distributed tracing: - * https://w3c.github.io/trace-context/ - * - * **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 - * ```typescript - * // Example incoming request with trace context: - * // traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 - * // ^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^ ^^ - * // | trace-id (32 chars) parent-span flags - * - * 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() - - // Try to extract from W3C Trace Context header (traceparent) - const traceparent = c.req.header('traceparent') - - 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(), // Generate new span for this service - parentSpanId, - timestamp, - } - } - } catch { - // Invalid traceparent format, fall through to generate new trace - } - } - - // No valid traceparent header, start a new trace - const requestId = c.req.header('x-request-id') || crypto.randomUUID() - const traceId = generateTraceId() - - return { - requestId, - traceId, - spanId: generateSpanId(), - timestamp, - } -} - -/** - * 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 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') -} - -/** - * Helper to extract correlation context from Hono context. - * - * 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 c - Hono context - * @returns Correlation context containing request/trace/span IDs - * - * @example - * ```typescript - * export const handler = (c) => { - * const correlation = getCorrelation(c) - * - * // Use in custom integrations - * await sendToMetrics({ - * traceId: correlation.traceId, - * spanId: correlation.spanId, - * // ... - * }) - * } - * ``` - */ -export function getCorrelation(c: Context): RequestCorrelation { - return c.get('correlation') as RequestCorrelation -} - -/** - * Helper to extract logger from Hono context - * - * Provides type-safe logger access in handlers after correlationMiddleware. - * - * @param c - Hono context - * @returns Logger instance - * - * @example - * export const handler: EndpointHandler = (c) => { - * const logger = getLogger(c) - * logger.info('Processing request') - * } - */ -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 get headers for propagating trace context to downstream services. - * - * 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 object ready to use in fetch/HTTP clients - * - * @example - * ```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 - const headers: Record = {} - - traceHeaders?.forEach((value, key) => { - headers[key] = value - }) - - return headers -} \ No newline at end of file diff --git a/edge/_shared/middleware/validation.ts b/edge/_shared/middleware/validation.ts deleted file mode 100644 index 2dcb10e..0000000 --- a/edge/_shared/middleware/validation.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Validation middleware with RFC 7807 compliant error responses - */ - -import type { StandardSchemaV1 } from '@standard-schema/spec' -import type { MiddlewareHandler, ValidationTargets } from 'hono' -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 '../response/mod.ts' - -import { getDotPath } from '@standard-schema/utils' - -/** - * Validation middleware that returns RFC 7807 compliant errors for all validation failures. - * - * It handles: - * - Standard Schema validation failures via `result.success === false` - * - Thrown ZodError instances (e.g. from transforms or inner zod schemas) - * - * In both cases it returns a 422 with an `errors` array. - */ -export function createValidator< - T extends StandardSchemaV1, - Target extends keyof ValidationTargets = keyof ValidationTargets ->( - target: Target, - schema: T, -): MiddlewareHandler { - // Inner validator provided by @hono/standard-validator - const validator = sValidator(target, schema, (result, c) => { - if (!result.success) { - const logger = getLogger(c) - - // Transform Standard Schema issues to validation error details - const errors = toErrs(result.error) - - logger.warn('Validation failed', { - target: String(target), - path: c.req.path, - error_count: errors.length, - errors: errors.map((e) => `${e.field}: ${e.message}`), - }) - - // RFC 7807 validation error array - return c.json(...validationFailed(c.req.path, errors)) - } - }) - - // Wrap the validator to catch thrown ZodErrors and convert them to 422s - return async (c, next) => { - try { - // Important: await so we catch async rejections - return await validator(c, next) - } catch (err) { - // If we already turned it into an HTTPException, let it bubble - if (err instanceof HTTPException) { - throw err - } - - const logger = getLogger(c) - - // If a raw ZodError leaked out from inside a schema, treat it as a validation failure - if (err instanceof ZodError) { - const errors = err?.issues?.map(issue => ({ - field: (issue.path?.length ?? 0) > 0 ? issue.path.join('.') : '_root', - message: issue.message, - })) ?? [] - - logger.warn('Validation failed (ZodError)', { - target: String(target), - path: c.req.path, - error_count: errors.length, - errors: errors.map((e) => `${e.field}: ${e.message}`), - }) - - return c.json(...validationFailed(c.req.path, errors)) - } - - // Anything else is a genuine server error → 500 - logger.error('Unexpected error in validation middleware', { - path: c.req.path, - target: String(target), - error_name: err?.constructor?.name, - message: err instanceof Error ? err.message : String(err), - }) - - return c.json(...internalServerError(c.req.path)) - } - } -} - -/** Convert Standard Schema issues → ValidationErrorDetail[]. */ -export function toErrs( - issues: StandardSchemaV1.Issue[] | readonly StandardSchemaV1.Issue[], -): ValidationErrorDetail[] { - return issues.map((issue) => ({ - field: getDotPath(issue) ?? '_root', - message: issue.message, - })) -} diff --git a/edge/_shared/query/fields.ts b/edge/_shared/query/fields.ts deleted file mode 100644 index af724a3..0000000 --- a/edge/_shared/query/fields.ts +++ /dev/null @@ -1,383 +0,0 @@ -// utils/query/fields.ts -/** - * Field selection (sparse fieldsets) with multi-source support - * - * Features: - * - JSON:API syntax: fields[type]=a,b,c - * - Simple syntax: fields=a,b,c - * - Field allowlists via registry - * - De-duplication - * - Wildcard handling - * - Query/JSON/FormData source adapters - */ - -import { z } from 'zod' - -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '#shared/server/schemas.ts' -import { - FieldSelectionNormalizedSchema, - type FieldsConfig, - type FieldSelectionNormalized -} from './schemas.ts' - -// ============================================================================ -// WIRE SCHEMAS (raw incoming data) -// ============================================================================ - -/** - * Query parameter wire schema (raw incoming) - * Supports both: - * - Simple: ?fields=a,b,c - * - JSON:API: ?fields[products]=a,b,c&fields[categories]=x,y - */ -export const FieldsQueryWire = BaseQuerySchema - -/** - * JSON body wire schema (raw incoming) - * Expects: { fields: { type: 'simple', fields: ['a', 'b'] } } - */ -export const FieldsJsonWire = BaseJsonSchema.pipe( - z.object({ - fields: FieldSelectionNormalizedSchema.nullable().default(null) - }) -) - -/** - * FormData wire schema (raw incoming) - */ -export const FieldsFormWire = BaseFormSchema - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/** - * Extract string value from ZStringOrStringArray - */ -const getString = (val: unknown): string | undefined => { - if (Array.isArray(val)) return val[0] - if (typeof val === 'string') return val - return undefined -} - -/** - * Detect JSON:API vs simple syntax from query/form data - * - * Detection priority: - * 1. Check for JSON:API syntax: fields[type]=... - * 2. Fall back to simple syntax: fields=... - * 3. Return null if neither found - * - * @param data Raw query or form data - * @returns Normalized field selection or null - * - * @example - * // JSON:API syntax - * detectFieldSyntax({ 'fields[products]': 'id,name', 'fields[categories]': 'name' }) - * // => { type: 'jsonapi', fields: { products: ['id', 'name'], categories: ['name'] } } - * - * // Simple syntax - * detectFieldSyntax({ fields: 'id,name,price' }) - * // => { type: 'simple', fields: ['id', 'name', 'price'] } - * - * // No field selection - * detectFieldSyntax({ page: '1' }) - * // => null - */ -function detectFieldSyntax(data: Record): FieldSelectionNormalized | null { - // Check for JSON:API syntax: fields[type]=... - const jsonApiFields: Record = {} - - for (const [key, value] of Object.entries(data)) { - const match = key.match(/^fields\[([^\]]+)\]$/) - if (match) { - const type = match[1] - - // Extract value (handle arrays from ZStringOrStringArray) - const stringValue = String(getString(value) ?? '') - const fields = stringValue - .split(',') - .map(f => f.trim()) - .filter(f => f) - - if (fields.length > 0) { - jsonApiFields[type] = fields - } - } - } - - if (Object.keys(jsonApiFields).length > 0) { - return { type: 'jsonapi', fields: jsonApiFields } - } - - // Fall back to simple syntax: fields=... - const fieldsValue = data.fields - if (fieldsValue) { - // Extract value (handle arrays from ZStringOrStringArray) - const stringValue = String(getString(fieldsValue) ?? '') - const fields = stringValue - .split(',') - .map(f => f.trim()) - .filter(f => f) - - if (fields.length > 0) { - return { type: 'simple', fields } - } - } - - return null -} - -/** - * Encode field selection back to wire format - * Used for round-trip testing - */ -function encodeFieldSyntax(selection: FieldSelectionNormalized | null): Record { - if (!selection) return {} - - if (selection.type === 'simple') { - return { fields: selection.fields.join(',') } - } - - // JSON:API syntax - const result: Record = {} - for (const [type, fields] of Object.entries(selection.fields)) { - result[`fields[${type}]`] = fields.join(',') - } - return result -} - -// ============================================================================ -// SOURCE ADAPTERS (unidirectional - decode only) -// ============================================================================ - -/** - * Query parameter adapter (codec for round-trip testing) - * Supports: ?fields=a,b,c or ?fields[products]=a,b,c - * - * @example - * const adapter = createFieldsQueryAdapter() - * - * // Simple syntax - * const simple = adapter.decode({ fields: 'id,name,price' }) - * // => { type: 'simple', fields: ['id', 'name', 'price'] } - * - * // JSON:API syntax - * const jsonapi = adapter.decode({ - * 'fields[products]': 'id,name', - * '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, - FieldSelectionNormalizedSchema.nullable(), - { - decode: (raw): FieldSelectionNormalized | null => { - return detectFieldSyntax(raw) - }, - encode: (selection): z.input => { - return encodeFieldSyntax(selection) - } - } - ) -} - - -/** - * JSON body adapter (codec for round-trip testing) - * Expects: { fields: { type: 'simple', fields: ['a', 'b'] } } - * - * @example - * const adapter = createFieldsJsonAdapter() - * const normalized = adapter.decode({ - * fields: { type: 'simple', fields: ['id', 'name'] } - * }) - * // => { type: 'simple', fields: ['id', 'name'] } - */ -export function createFieldsJsonAdapter() { - return z.codec( - FieldsJsonWire, - FieldSelectionNormalizedSchema.nullable(), - { - decode: (raw): FieldSelectionNormalized | null => { - return raw.fields as FieldSelectionNormalized | null - }, - encode: (selection): z.input => { - return { fields: selection } - } - } - ) -} - -/** - * FormData adapter (codec for round-trip testing) - * Supports: fields=a,b,c or fields[products]=a,b,c - * - * @example - * const adapter = createFieldsFormAdapter() - * const formData = new FormData() - * formData.append('fields', 'id,name,price') - * const normalized = adapter.decode(formData) - * // => { type: 'simple', fields: ['id', 'name', 'price'] } - */ -export function createFieldsFormAdapter() { - return z.codec( - FieldsFormWire, - FieldSelectionNormalizedSchema.nullable(), - { - decode: (raw): FieldSelectionNormalized | null => { - // Convert FormValue to plain record for detectFieldSyntax - const plainObj: Record = {} - - for (const [key, value] of Object.entries(raw)) { - plainObj[key] = String(getString(value) ?? '') - } - - return detectFieldSyntax(plainObj) - }, - encode: (selection): z.input => { - return encodeFieldSyntax(selection) - } - } - ) -} - - -// ============================================================================ -// SCHEMA COMPOSITION WITH VALIDATION -// ============================================================================ - -/** - * Create endpoint-specific fields schema with validation - * - * @param config Configuration for field selection validation - * @param config.source Input source type ('query' | 'json' | 'form') - * @param config.allowedFields Array of selectable field names (allowlist) - * @param config.resourceType Resource type for JSON:API validation - * @param config.defaultFields Default fields when none provided - * - * @example - * const schema = createFieldsSchema({ - * source: 'query', - * allowedFields: ['id', 'name', 'price', 'created_at'], - * defaults: ['id', 'name'] - * }) - * - * // Parse and validate - * const fields = schema.parse({ fields: 'id,name,price' }) - * // => { type: 'simple', fields: ['id', 'name', 'price'] } - * - * // With wildcard - * const allFields = schema.parse({ fields: '*' }) - * // => { type: 'simple', fields: ['id', 'name', 'price', 'created_at'] } - * - * // Invalid field rejected - * schema.parse({ fields: 'id,invalid_field' }) - * // => Throws validation error - */ -export function createFieldsSchema(config: { - source: 'query' | 'json' | 'form' -} & FieldsConfig) { - 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 => { - if (!selection && config.defaults && config.defaults.length > 0) { - return { - type: 'simple', - fields: [...config.defaults] - } - } - return selection - }) - - // Step 2: Validate - .check((ctx) => { - const selection = ctx.value - if (!selection) return - - // Get fields to validate based on selection type - const fieldsToValidate = - selection.type === 'simple' - ? selection.fields - : config.resourceType - ? selection.fields[config.resourceType] ?? [] - : Object.values(selection.fields).flat() - - // De-duplicate fields - const uniqueFields = Array.from(new Set(fieldsToValidate)) - - // Handle wildcard - skip validation, will expand in transform - if (uniqueFields.includes('*')) { - if (!config.allowedFields) return - return // Wildcard expansion happens in transform - } - - // Validate against allowlist - if (config.allowedFields && config.allowedFields.length > 0) { - const invalidFields = uniqueFields.filter(f => !config.allowedFields!.includes(f)) - - if (invalidFields.length > 0) { - ctx.issues.push({ - code: "custom", - path: selection.type === 'simple' ? ['fields'] : ['fields', config.resourceType ?? '*'], - message: `Invalid fields: ${invalidFields.join(', ')}. Allowed fields: ${config.allowedFields.join(', ')}`, - input: selection - }) - } - } - }) - - // Step 3: Transform (expand wildcards, deduplicate) - .transform((selection): FieldSelectionNormalized | null => { - if (!selection) return null - - if (selection.type === 'simple') { - 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 - } - }) -} diff --git a/edge/_shared/query/filtering.ts b/edge/_shared/query/filtering.ts deleted file mode 100644 index dd59a21..0000000 --- a/edge/_shared/query/filtering.ts +++ /dev/null @@ -1,820 +0,0 @@ -// utils/query/filtering.ts - -/** - * Query Filtering — URL shapes, parsing, validation, and why `arrayOperators` exist. - * - * ## The two client-facing representations - * - * 1) **Bracket notation (URL-friendly; recommended for GET)** - * Structure: `filter[][]=` - * - * - Operator is optional; missing operator implies `eq`. - * Example: - * `/api/posts?filter[status]=published` - * → `status eq 'published'` (operator defaults to `eq`). - * - * - Multiple filters AND together: - * `/api/products?filter[category]=electronics&filter[price][gte]=50&filter[price][lte]=200` - * → `category = 'electronics' AND price >= 50 AND price <= 200`. - * - * - Null checks use keywords (not booleans): - * `/api/posts?filter[deleted_at]=null` → `IS NULL` - * `/api/posts?filter[published_at]=not_null` → `IS NOT NULL`. - * - * - Set operators use comma-separated lists in the URL: - * `/api/issues?filter[status][in]=open,in_review,blocked` - * `/api/issues?filter[status][nin]=wontfix,duplicate`. - * - * Parsing flow: - * - `parseBracketNotation(...)` walks query keys with `/^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/`, - * extracts `{ field, operator? }`, defaults operator to `eq`, and turns `null` / `not_null` - * into `is_null` / `is_not_null`. The rest pass through as `{ field, operator, value }`. - * This produces a `FiltersNormalized[]` array for downstream validation. - * - * 2) **Structured JSON filters (body POST/PUT), or URL-encoded JSON as a single param** - * Shape: - * ```json - * { "filters": [ - * { "field": "category", "operator": "eq", "value": "electronics" }, - * { "field": "price", "operator": "gte","value": "50" } - * ] } - * ``` - * - Use `createFiltersJsonAdapter()` to parse the request body into `FiltersNormalized`. - * If you must keep GET-only, you *may* URL-encode the JSON into a single `filters` param - * and parse it prior to validation, then feed this same adapter. - * - * ## Where `arrayOperators` matter - * - * - 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. - * - * - In bracket notation, clients still send comma-separated lists; validation reads the registry - * to decide whether to split/coerce into arrays. In JSON, clients send actual arrays for `value`. - * Either way, we normalize to the *same* `FiltersNormalized` shape. - * - * ## 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/ - * boolean/date/enum/uuid) and allowed enum values. - * - * - **Type-aware coercion**: The validator converts values based on the field type and operator: - * - numbers for `gt/gte/lt/lte/eq/ne` (rejects NaN), - * - booleans for `eq/ne`, - * - dates to ISO strings for `gt/gte/lt/lte/eq/ne` (rejects invalid dates), - * - enum membership checks, - * - UUID format checks, etc. - * - * - **Null operators** never require a `value` (`is_null`, `is_not_null`). - * - * - **Array operators** (as declared per field) require arrays; URL commas split into arrays. - * (We also recommend adding per-endpoint caps on list lengths via `LimitsConfig` to avoid abuse.) - * - * - **DoS protection**: We cap the **number of filters** per request (default 20; configurable) - * and fail fast if exceeded. - * - * ## Security & performance notes - * - * - **Security**: Always allowlist fields/operators and type-check values before building queries. - * 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 - * for cursor-based pagination where tiebreakers (e.g., `created_at,id`) must be indexed. - * - * ## Worked examples (URL) - * - * - Equality (implicit operator): - * `/api/posts?filter[status]=published` → `{ field:'status', operator:'eq', value:'published' }` - * - * - Ranged price: - * `/api/products?filter[price][gte]=50&filter[price][lte]=200` → two normalized filters; numbers coerced. - * - * - Null checks: - * `/api/posts?filter[deleted_at]=null&filter[published_at]=not_null` → `is_null` / `is_not_null` (no values). - * - * - Set membership (array operator via registry): - * `/api/issues?filter[status][in]=open,in_review,blocked` - * → value coerced to `['open','in_review','blocked']` when `status` declares `in` under `arrayOperators`. - * - * ## Worked examples (JSON) - * - * ```json - * { - * "filters": [ - * { "field": "category", "operator": "eq", "value": "electronics" }, - * { "field": "price", "operator": "gte", "value": "50" }, - * { "field": "status", "operator": "in", "value": ["open","in_review","blocked"] } - * ] - * } - * ``` - * - Parsed via `createFiltersJsonAdapter()` into the same `FiltersNormalized[]`; array handling - * and type coercion follow the registry+validation rules above. - * - * --- - * - * Implementation notes: - * - The adapters (`createFiltersQueryAdapter`, `createFiltersJsonAdapter`, `createFiltersFormAdapter`) - * 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(...).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"]) - * • Apply caps/limits (e.g. max filters) - * - * - This separation keeps parsing concerns **independent from** validation rules, - * making the system easier to extend and reason about. - */ - -import { z } from 'zod' - -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema } from '#shared/server/schemas.ts' -import { - FiltersNormalizedSchema, - type FilterNormalized, - type FiltersNormalized, - type FilterOperator, - type FilterRegistry, - type FiltersConfig, -} from './schemas.ts' - -// ============================================================================ -// WIRE SCHEMAS (raw incoming data) -// ============================================================================ - -/** - * Query parameter wire schema (raw incoming) - * Supports: ?filter[category][eq]=electronics&filter[price][gte]=50 - */ -export const FiltersQueryWire = BaseQuerySchema - -/** - * JSON body wire schema (raw incoming) - * Expects: { filters: [{ field: 'category', operator: 'eq', value: 'electronics' }] } - */ -export const FiltersJsonWire = BaseJsonSchema.pipe( - z.object({ - filters: FiltersNormalizedSchema.default([]) - }) -) - -/** - * FormData wire schema (raw incoming) - */ -export const FiltersFormWire = BaseFormSchema - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/** - * Parse bracket notation from query parameters or form data - * Syntax: filter[field][operator]=value - * - * @param data Raw query or form data - * @returns Array of normalized filters - * - * @example - * parseBracketNotation({ - * 'filter[status]': 'published', - * 'filter[price][gte]': '50' - * }) - * // => [ - * // { field: 'status', operator: 'eq', value: 'published' }, - * // { field: 'price', operator: 'gte', value: '50' } - * // ] - */ -function parseBracketNotation(data: Record): FiltersNormalized { - const filters: FilterNormalized[] = [] - - for (const [key, rawValue] of Object.entries(data)) { - const match = key.match(/^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/) - if (!match) continue - - const field = match[1] - const operator = (match[2] || 'eq') as FilterOperator - - // Extract value (handle arrays from ZStringOrStringArray) - let value: string - if (Array.isArray(rawValue)) { - value = String(rawValue[0] ?? '') - } else { - value = String(rawValue ?? '') - } - - // Handle null keywords - if (value === 'null') { - filters.push({ field, operator: 'is_null' }) - } else if (value === 'not_null') { - filters.push({ field, operator: 'is_not_null' }) - } else { - filters.push({ field, operator, value }) - } - } - - return filters -} - -/** - * Encode filters back to bracket notation - * Used for round-trip testing - * - * @param filters Normalized filters array - * @returns Wire format object - * - * @example - * encodeBracketNotation([ - * { field: 'status', operator: 'eq', value: 'published' }, - * { field: 'price', operator: 'gte', value: 50 } - * ]) - * // => { - * // 'filter[status]': 'published', - * // 'filter[price][gte]': '50' - * // } - */ -function encodeBracketNotation(filters: FiltersNormalized): Record { - const result: Record = {} - - for (const filter of filters) { - // Handle null operators (no value) - if (filter.operator === 'is_null') { - result[`filter[${filter.field}]`] = 'null' - continue - } - - if (filter.operator === 'is_not_null') { - result[`filter[${filter.field}]`] = 'not_null' - continue - } - - // Handle value-based operators - const value = filter.value - const stringValue = Array.isArray(value) - ? value.join(',') - : String(value ?? '') - - // Use bracket notation for non-eq operators - const key = filter.operator === 'eq' - ? `filter[${filter.field}]` - : `filter[${filter.field}][${filter.operator}]` - - result[key] = stringValue - } - - return result -} - -// ============================================================================ -// SOURCE ADAPTERS (bidirectional codecs for testing) -// ============================================================================ - -/** - * Query parameter adapter (codec for round-trip testing) - * Supports: ?filter[category][eq]=electronics&filter[price][gte]=50 - * - * @example - * const adapter = createFiltersQueryAdapter() - * const normalized = adapter.decode({ - * 'filter[status]': 'published', - * 'filter[price][gte]': '50' - * }) - * // => [ - * // { field: 'status', operator: 'eq', value: 'published' }, - * // { field: 'price', operator: 'gte', value: '50' } - * // ] - * - * // Encode (for testing) - * const wire = adapter.encode(normalized) - */ -export function createFiltersQueryAdapter() { - return z.codec( - FiltersQueryWire, - FiltersNormalizedSchema, - { - decode: (raw): FiltersNormalized => { - return parseBracketNotation(raw) - }, - encode: (normalized): z.input => { - return encodeBracketNotation(normalized) - } - } - ) -} - -/** - * JSON body adapter (codec for round-trip testing) - * Expects: { filters: [{ field: 'category', operator: 'eq', value: 'electronics' }] } - * - * @example - * const adapter = createFiltersJsonAdapter() - * const normalized = adapter.decode({ - * filters: [{ field: 'category', operator: 'eq', value: 'electronics' }] - * }) - */ -export function createFiltersJsonAdapter() { - return z.codec( - FiltersJsonWire, - FiltersNormalizedSchema, - { - decode: (raw): FiltersNormalized => { - return raw.filters as FiltersNormalized - }, - encode: (normalized): z.input => { - return { filters: normalized } - } - } - ) -} - -/** - * FormData adapter (codec for round-trip testing) - * Supports same bracket notation as query adapter - * - * @example - * const adapter = createFiltersFormAdapter() - * const formData = new FormData() - * formData.append('filter[status]', 'published') - * const normalized = adapter.decode(formData) - */ -export function createFiltersFormAdapter() { - return z.codec( - FiltersFormWire, - FiltersNormalizedSchema, - { - decode: (raw): FiltersNormalized => { - return parseBracketNotation(raw) - }, - encode: (normalized): z.input => { - return encodeBracketNotation(normalized) - } - } - ) -} - -// ============================================================================ -// VALIDATION HELPERS -// ============================================================================ - -/** - * Result type for coercion operations - */ -type CoercionResult = - | { ok: true; value: unknown } - | { ok: false } - -/** - * Validate and coerce operator value based on field type - * 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, - path: (string | number)[] -): CoercionResult { - const { operator, value, field } = filter - - // Null operators don't need values - if (operator === 'is_null' || operator === 'is_not_null') { - return { ok: true, value: undefined } - } - - // Between operator needs exactly 2 values - if (operator === 'between') { - let arrayValue: unknown[] - if (Array.isArray(value)) { - arrayValue = value - } else if (typeof value === 'string') { - arrayValue = value.split(',').map(v => v.trim()).filter(Boolean) - } else { - arrayValue = [value] - } - - if (arrayValue.length !== 2) { - ctx.issues.push({ - code: 'custom', - input: filter, - path: [...path, 'value'], - message: `Operator 'between' on field '${field}' requires exactly 2 values (min,max). Got ${arrayValue.length}.` - }) - - return { ok: false } - } - - // Coerce based on field type - if (fieldDef.type === 'number') { - const [min, max] = arrayValue.map(Number) - if (Number.isNaN(min) || Number.isNaN(max)) { - ctx.issues.push({ - code: 'custom', - input: filter, - path: [...path, 'value'], - message: `Field '${field}' requires numeric values for 'between' operator` - }) - - return { ok: false } - } - - if (min > max) { - ctx.issues.push({ - code: 'custom', - input: filter, - path: [...path, 'value'], - message: `Field '${field}' 'between' operator requires min <= max. Got min=${min}, max=${max}.` - }) - - return { ok: false } - } - - return { ok: true, value: [min, max] } - } else if (fieldDef.type === 'date') { - const [minDate, maxDate] = arrayValue.map(v => new Date(String(v))) - if (Number.isNaN(minDate.getTime()) || Number.isNaN(maxDate.getTime())) { - ctx.issues.push({ - code: 'custom', - input: filter, - path: [...path, 'value'], - message: `Field '${field}' requires valid dates for 'between' operator` - }) - - return { ok: false } - } - - if (minDate > maxDate) { - ctx.issues.push({ - code: 'custom', - input: filter, - path: [...path, 'value'], - message: `Field '${field}' 'between' operator requires start date <= end date.` - }) - - return { ok: false } - } - - return { ok: true, value: [minDate.toISOString(), maxDate.toISOString()] } - } else { - ctx.issues.push({ - code: 'custom', - input: filter, - path: [...path, 'operator'], - message: `Operator 'between' not supported for field type '${fieldDef.type}' on field '${field}'` - }) - } - - return { ok: false } - } - - // Array operators need special handling - const isArrayOperator = fieldDef.arrayOperators?.includes(operator) ?? false - if (isArrayOperator) { - // Ensure value is an array - let arrayValue: unknown[] - if (Array.isArray(value)) { - arrayValue = value - } else if (typeof value === 'string') { - arrayValue = value.split(',').map(v => v.trim()).filter(Boolean) - } else { - arrayValue = [value] - } - - if (arrayValue.length === 0) { - 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.`, - input: filter - }); - - return { ok: false } - } - - // 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 - switch (fieldDef.type) { - case 'number': - if (['gt', 'gte', 'lt', 'lte', 'eq', 'ne'].includes(operator)) { - const numValue = Number(value) - if (Number.isNaN(numValue)) { - ctx.issues.push({ - code: "custom", - input: filter, - path: [...path, 'value'], - message: `Field '${field}' requires numeric value for operator '${operator}'` - }) - - return { ok: false } - } - - return { ok: true, value: numValue } - } - break - - case 'boolean': - if (operator === 'eq' || operator === 'ne') { - 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 (Number.isNaN(dateValue.getTime())) { - ctx.issues.push({ - code: "custom", - input: filter, - path: [...path, 'value'], - message: `Field '${field}' requires valid date for operator '${operator}'` - }) - - return { ok: false } - } - - return { ok: true, value: dateValue.toISOString() } - } - break - - case 'enum': - if (operator === 'eq' || operator === 'ne') { - const strValue = String(value) - if (fieldDef.values && !fieldDef.values.includes(strValue)) { - ctx.issues.push({ - code: "custom", - input: filter, - path: [...path, 'value'], - message: `Invalid value '${strValue}' for enum field '${field}'. Allowed values: ${fieldDef.values.join(', ')}` - }) - - return { ok: false } - } - - return { ok: true, value: strValue } - } - break - - case 'uuid': - if (operator === 'eq' || operator === 'ne') { - const uuidCheck = z.uuid().safeParse(String(value)) - if (!uuidCheck.success) { - 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 - 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 }; -} - -// ============================================================================ -// SCHEMA COMPOSITION WITH VALIDATION -// ============================================================================ - -/** - * Create endpoint-specific filters schema with validation - * - * 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') - * @param config.registry Filter registry with field definitions - * @param config.limits Optional limits configuration (maxFilters) - * - * @example - * const schema = createFiltersSchema({ - * source: 'query', - * registry: { - * price: { - * operators: ['gte', 'lte'], - * type: 'number' - * }, - * status: { - * operators: ['eq', 'in'], - * type: 'enum', - * values: ['draft', 'published'], - * arrayOperators: ['in'] - * } - * }, - * limits: { maxFilters: 10 } - * }) - * - * // Parse and validate - * const filters = schema.parse({ - * 'filter[price][gte]': '50', - * 'filter[status][in]': 'draft,published' - * }) - */ -export function createFiltersSchema(config: { - source: 'query' | 'json' | 'form' -} & FiltersConfig) { - // 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 - - const registry = config.registry ?? {} - const allowedFields = Object.keys(registry) - - return adapter - // Step 1: Apply defaults (pure transform) - .transform((filters): FiltersNormalized => { - // Apply default 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 - }) - - // Step 2: Validate structure (no mutations) - .check((ctx) => { - const filters = ctx.value - - // Check filter count (DoS protection) - if (filters.length > maxFilters) { - ctx.issues.push({ - code: "too_big", - maximum: maxFilters, - origin: 'array', - path: [], - 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 - - // Validate each filter - filters.forEach((filter, idx) => { - const { field, operator = 'eq' } = filter - const fieldDef = registry[field] - - // Check if field is filterable - if (!fieldDef && allowedFields.length > 0) { - ctx.issues.push({ - code: "custom", - input: filter, - path: [idx, 'field'], - 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 && !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 - } - - }) - }) - - // 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/edge/_shared/query/mod.ts b/edge/_shared/query/mod.ts deleted file mode 100644 index c933829..0000000 --- a/edge/_shared/query/mod.ts +++ /dev/null @@ -1,54 +0,0 @@ -// utils/query/mod.ts -/** - * Public surface for query utilities. - * - Adapters and schema factories - * - Cursor helpers and executor - */ - -export * from './schemas.ts' -export * from './filtering.ts' -export * from './sorting.ts' -export * from './fields.ts' -export * from './pagination.ts' -export * from './query.ts' - -/** - * Example (Hono-like) handler usage - * - * import { createEndpoindQuerySpec } from './schemas.ts' - * import { createValidator } from '#shared/middleware/validation.ts' // your existing middleware factory - * - * 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' } } }, - * sorts: { allowedFields: ['created_at','title'] }, - * fields: { allowlist: ['*','id','title','created_at','posts.*'] }, - * pagination: { cursorSecret: Deno.env.get('CURSOR_SECRET') ?? '' }, - * })) - * - * app.get('/items', QueryValidator, async (c) => { - * const spec = c.req.valid('query') // -> QuerySpec - * - * const result = await executeListQuery( - * { secret: Deno.env.get('CURSOR_SECRET') ?? undefined, ttlSec: 3600 }, - * spec, - * async ({ filters, sorts, fields, pagination }) => { - * // TODO: translate filters/sorts/fields/pagination → your DB query - * return { rows: [], count: 0 } - * } - * ) - * - * if (result.errorType === 'cursor_expired') { - * return c.json(...gone(c.req.path, 'Cursor expired')) - * } - * if (result.errorType === 'cursor_invalid') { - * return c.json(...badRequest(c.req.path, 'Invalid cursor')) - * } - * - * // Use your existing paginate() response helper (data + meta + headers in tuple) - * // Example expects you to compute pagination meta (next/prev cursors) alongside rows. - * return c.json(...paginate(c.req.url, result.data ?? [], { count: result.count ?? 0 })) - * }) - */ diff --git a/edge/_shared/query/pagination.ts b/edge/_shared/query/pagination.ts deleted file mode 100644 index 331986c..0000000 --- a/edge/_shared/query/pagination.ts +++ /dev/null @@ -1,668 +0,0 @@ -// utils/query/pagination.ts -/** - * Cursor-based and offset-based pagination with multi-source support - * - * Features: - * - Auto-detects cursor vs offset from any source - * - HMAC-signed cursors with expiration - * - Configurable limits per endpoint - * - 410 Gone for expired cursors - * - Query/JSON/FormData source adapters - */ - -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 { decodeBase64Url, encodeBase64Url } from '@bundle/utils/encoding' - -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '../server/schemas.ts' -import { - PaginationNormalizedSchema, - CursorDataSchema, - type CursorData, -} from './schemas.ts' -import { badRequest, ok, gone } from '../response/mod.ts' -import { isSuccessResponse } from '../response/success.ts' - -// ============================================================================ -// CURSOR ENCODING/DECODING -// ============================================================================ - -const EncodedCursorSchema = z.object({ - data: CursorDataSchema, - signature: z.hex(), -}).strict() - -/** - * @based on `@mofax/sorted-stringify` https://jsr.io/@mofax/sorted-stringify/0.0.4/index.ts - * Recursively sorts the keys of an object or elements of an array. - * - * - If the input is not an object or array, the value is returned as is. - * - If the input is an array, it recursively sorts each element in the array. - * - If the input is an object, it sorts the object by its keys and recursively sorts the values. - * - * @param obj - The input object, array, or any other value to be sorted. - * @returns The sorted object, array, or the original value if it's not an object or array. - * - * @example - * ```typescript - * const obj = { - * z: 1, - * a: { c: 3, b: 2 }, - * array: [ { b: 2, a: 1 }, 3 ] - * }; - * const sortedObj = sortObj(obj); - * console.log(sortedObj); - * // Output: - * // { - * // a: { b: 2, c: 3 }, - * // array: [ { a: 1, b: 2 }, 3 ], - * // z: 1 - * // } - * ``` - */ -export function sortObject(obj: T): T { - if (obj instanceof Date) return obj.toISOString() as T; // normalize precision - if (obj == null || typeof obj !== "object") return obj; - if (ArrayBuffer.isView(obj) || obj instanceof ArrayBuffer) return obj as T; - if (Array.isArray(obj)) return obj.map(sortObject) as T; - - return Object.entries(obj) - .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) - .reduce((sortedObj: Record, [key, value]) => { - sortedObj[key] = sortObject(value); - return sortedObj; - }, {}) as T; -} - -/** - * Codec: string (base64url JSON) <-> { data, signature } - * - decode(): wire -> structured - * - encode(): structured -> wire - * - * This is a TRUE codec because encodeCursor() uses .encode() - */ -export const Base64UrlJsonCursorCodec = z.codec( - z.string(), // wire type (Input) - EncodedCursorSchema, // domain type (Output) - { - decode: (token) => { - // base64url decode - const bytes = decodeBase64Url(token); - const json = new TextDecoder().decode(bytes); - return JSON.parse(json); - }, - encode: (obj) => { - 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. - // The order of the objects keys affects how the object is stringified - const data = sortObject(payload); - const json = JSON.stringify(data); - return createHmac("sha256", secret) - .update(json) - .digest("hex"); -} - -/** - * Decode + verify a cursor token. Return domain result or throw mapped HTTP errors. - * - 400 if format/signature invalid - * - 410 if expired - */ -export function decodeAndVerifyCursor( - token: string, - secret: string, - ttlSeconds = 86_400, // 24h -) { - if (!token) { - return badRequest(token, "Invalid cursor token") - } - - // 1) Decode token -> { data, signature } - const { data: decoded, error } = Base64UrlJsonCursorCodec.safeDecode(token); - if (error) { - return badRequest(token, "Cannot decode cursor token") - } - - const parsed = EncodedCursorSchema.safeParse(decoded); - - if (!parsed.success) { - // Map Zod issues to your error envelope at the HTTP boundary: - return badRequest(token, `Invalid cursor: ${parsed.error.issues?.[0]?.message}`) - } - - const { data, signature } = parsed.data; - - // 2) Verify HMAC - const expected = hmacSha256Hex(secret, data); - if (signature !== expected) { - return badRequest(token, "Cursor signature mismatch") - } - - // 3) Verify TTL (use epoch seconds; avoid Date#getDate bug) - const nowSec = Math.floor(Date.now() / 1000); - const createdSec = Math.floor(data.createdAt.getTime() / 1000); - const age = nowSec - createdSec; - - if (age > ttlSeconds) { - return gone(String(age), `Cursor has expired (${age - ttlSeconds})`, { - deltaSeconds: age - ttlSeconds, - }); - } - - // 4) Success: return normalized CursorData - return ok(data, 200); -} - -/** - * 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 = { - data, - signature: hmacSha256Hex(secret, data), - }; - - return Base64UrlJsonCursorCodec.encode(envelope); -} - -// ============================================================================ -// WIRE SCHEMAS -// ============================================================================ - -// Query wire schema (raw incoming) -export const PaginationQueryWire = BaseQuerySchema.extend({ - offset: ZStringOrStringArray.optional(), - limit: ZStringOrStringArray.optional(), - page: ZStringOrStringArray.optional(), - per_page: ZStringOrStringArray.optional(), - cursor: ZStringOrStringArray.optional() -}) - -// JSON wire schema (raw incoming) -export const PaginationJsonWire = BaseJsonSchema - -// Form wire schema (raw incoming) -export const PaginationFormWire = BaseFormSchema - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -// Extract first value if array -const getString = (val: unknown): string | undefined => { - if (Array.isArray(val)) return val[0] - if (typeof val === 'string') return val - return undefined -} - -// Extract first value of a specific key if array of string -const pickForm = (raw: Record, key: string): string | undefined => { - const val = getString(raw[key]) - if (val) return String(val) - 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 (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, - PaginationNormalizedSchema, - { - decode: (raw): PaginationNormalized => { - return decodePagination(raw, defaultLimit) - }, - encode: (norm): z.input => { - const normalized = PaginationNormalizedSchema.parse(norm) - return encodePagination(normalized) - }, - } - ) -} - -/** - * 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({ - pagination: PaginationNormalizedSchema.optional().default({ - type: 'offset', - offset: 0, - limit: defaultLimit - }) - }) - - return z.codec( - PaginationJsonWire, - PaginationNormalizedSchema, - { - decode: (raw): PaginationNormalized => { - return JsonEnvelope.parse(raw).pagination - }, - encode: (norm): z.input => { - return { pagination: norm } - }, - } - ) -} - -/** - * 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, - PaginationNormalizedSchema, - { - decode: (raw): PaginationNormalized => { - return decodePagination(raw, defaultLimit) - }, - encode: (norm): z.input => { - const normalized = PaginationNormalizedSchema.parse(norm) - return encodePagination(normalized) - }, - } - ) -} - -// ============================================================================ -// SCHEMA COMPOSITION WITH VALIDATION -// ============================================================================ - -/** - * Create endpoint-specific pagination schema with validation - * - * 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' -} & PaginationConfig) { - const limits = config.limits ?? {} as NonNullable - const defaultLimit = config.limits?.defaultLimit ?? 20 - - const adapter = - config.source === 'query' ? createPaginationQueryAdapter(defaultLimit) : - config.source === 'json' ? createPaginationJsonAdapter(defaultLimit) : - createPaginationFormAdapter(defaultLimit) - - 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 - }) - } - - 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 - }) - } - - // 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 -// ============================================================================ - -/** - * Create CursorData from a DB row. - * Keep this close to the query code so it's obvious which fields drive the cursor. - * - * @example - * const head = rows[0], tail = rows[rows.length - 1] - * const nextData = cursorFromRow(tail, { sortField: "created_at", tiebreaker: "id", direction: "asc" }) - * const prevData = cursorFromRow(head, { sortField: "created_at", tiebreaker: "id", direction: "desc" }) - */ -export function cursorFromRow>(row: Row, cfg: { - sortField: string; - tiebreaker: string; - direction: SortDirection; -}): CursorData { - const sortValue = row[cfg.sortField]; - const tieValue = row[cfg.tiebreaker]; - - // Normalize known primitives; let CursorDataSchema enforce the rest. - const data = { - sortField: cfg.sortField, - // 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); -} - -/** - * Compute next/prev tokens for keyset pages. - * - `hasMoreForward`: whether there are more rows after the last one you returned. - * - `hasMoreBackward`: whether there are rows before the first one (optional; set if you probe backward). - */ -export function makeCursorTokens>(args: { - items: Row[]; - limit: number; - sortField: string; - tiebreaker: string; - secret: string; - direction: "asc" | "desc"; - hasMoreForward: boolean; - hasMoreBackward?: boolean; -}) { - const count = args.items.length; - if (count === 0) { - return { - next: undefined as string | undefined, - prev: undefined as string | undefined - }; - } - - const head = args.items[0]; - const tail = args.items[count - 1]; - - // For "next" we point past the last item we actually returned. - const nextData: CursorData | undefined = args.hasMoreForward - ? cursorFromRow(tail, { sortField: args.sortField, tiebreaker: args.tiebreaker, direction: args.direction }) - : undefined; - - // For "prev" we point before the first item; invert direction to walk back. - const prevData: CursorData | undefined = args.hasMoreBackward - ? cursorFromRow(head, { sortField: args.sortField, tiebreaker: args.tiebreaker, direction: args.direction === "asc" ? "desc" : "asc" }) - : undefined; - - return { - next: nextData ? encodeCursor(nextData, args.secret) : undefined, - prev: prevData ? encodeCursor(prevData, args.secret) : undefined, - }; -} - -/** - * Computes a page expiry timestamp (ISO) from: - * - The cursor TTL policy (if cursor pagination) - * - An optional global page TTL (offset pagination or policy override) - * - * Semantics: - * - If you minted a "next" cursor, its expiry governs page expiry. - * - If both "next" and "prev" exist, choose the earliest. - * - If neither cursor exists (e.g., empty result or offset mode), use a global TTL (optional). - */ -export function computeExpiresAt(opts: { - now?: Date; // for testability - ttlSecs?: number; // same units you pass to decode/verify - hasCursor?: boolean; -}): Date | undefined { - const now = opts.now ?? new Date() - if (opts.hasCursor && typeof opts.ttlSecs === 'number') { - const expiry = new Date(now.getTime() + opts.ttlSecs * 1000) - return expiry - } - return undefined -} - -/** - * Comprehensive pagination bridge. - * - Trims rows via limit+1 strategy - * - Computes hasMore - * - Generates next/prev cursor tokens (cursor mode) - * - Computes next/prev offsets (offset mode) - * - Optionally computes expiresAt from TTL - */ -export function buildPaginationMeta>(args: { - rows: Row[]; - query: QuerySpec; - sortField?: string; - tiebreaker?: string; - direction?: "asc" | "desc"; - secret?: string; - ttlSec?: number; // optional TTL for expiresAt - total?: number; - approxTotal?: number; -}): { items: Row[]; } & PaginationMetadata { - const { rows, query } = args; - const limit = query.pagination.limit; - - // finalizePage logic - const hasMore = rows.length > limit; - const items = hasMore ? rows.slice(0, limit) : rows; - - // common base - const base: Pagination = { - hasMore, - limit, - count: items.length, - total: args.total, - approxTotal: args.approxTotal, - }; - - if (query.pagination.type === "cursor") { - const { sortField = "id", tiebreaker = "id", direction = "asc", secret = "" } = args; - - const { next, prev } = makeCursorTokens({ - items, - limit, - sortField, - tiebreaker, - secret, - direction, - hasMoreForward: hasMore, - // hasMoreBackward could be probed separately if needed - }); - - // optional expiry - if (args.ttlSec && args.ttlSec > 0) { - const expiresAt = computeExpiresAt({ - ttlSecs: args.ttlSec, - hasCursor: query.pagination.type === "cursor" - }) - base.expiresAt = expiresAt; - } - - return { - items, - pagination: { - ...base, - nextCursor: next, - prevCursor: prev, - }, - query - }; - } - - if (query.pagination.type === "offset") { - const nextOffset = hasMore ? query.pagination.offset + limit : undefined; - const prevOffset = query.pagination.offset > 0 ? Math.max(0, query.pagination.offset - limit) : undefined; - - return { - items, - pagination: { - ...base, - offset: query.pagination.offset, - ...(nextOffset !== undefined ? { nextOffset } : {}), - ...(prevOffset !== undefined ? { prevOffset } : {}), - }, - query - }; - } - - // exhaustive guard - return { items, pagination: base }; -} diff --git a/edge/_shared/query/query.ts b/edge/_shared/query/query.ts deleted file mode 100644 index 98980f8..0000000 --- a/edge/_shared/query/query.ts +++ /dev/null @@ -1,146 +0,0 @@ -// utils/query/query.ts -/** - * Composite schema factory for endpoint-specific query specs - * - * CHANGES: - * - createSingleSourceQuerySpec now respects disable flags - * - Disabled components return null instead of creating schemas - * - applyQuerySpec skips null components - * - * Features: - * - Source precedence (JSON > Query > Form) - * - Conflict detection - * - Complete QuerySpec type - * - Declarative endpoint configuration - * - Per-component enable/disable control - */ - -import { - EndpointQueryConfigSchema, - type EndpointQueryConfig, -} from './schemas.ts' - -import { createPaginationSchema } from './pagination.ts' -import { createFiltersSchema } from './filtering.ts' -import { createSortsSchema } from './sorting.ts' -import { createFieldsSchema } from './fields.ts' - -import { BaseFormSchema, BaseJsonSchema, BaseQuerySchema } from '../server/schemas.ts' -import z from 'zod' - -// Re-export QuerySpec type -export { QuerySpecSchema, type QuerySpec } from './schemas.ts' - -// ============================================================================ -// SINGLE-SOURCE QUERY SPEC FACTORY (UPDATED) -// ============================================================================ - -/** - * Build a single-source schema (query | json | form) applying business rules - * - * NEW: Respects disable flags in config: - * - disableFiltering: Always returns null for filters - * - disableSorting: Always returns null for sorts - * - disableFields: Always returns null for fields - * - Pagination always enabled (required for collections) - * - * @param source Input source type - * @param configInput Endpoint configuration - * @returns Zod schema that parses and validates complete query spec - * - * @example - * // Endpoint with fixed fields and sorting - * const schema = createSingleSourceQuerySpec('query', { - * fieldRegistry: { - * filterable: ['status'], - * sortable: ['created_at', 'id'], - * selectable: ['id', 'title'] - * }, - * disableFields: true, // Ignore field selection requests - * disableSorting: true, // Ignore sort requests - * defaultSort: [{ field: 'created_at', direction: 'desc' }] - * }) - * - * @example - * // Read-only endpoint (no filtering) - * const schema = createSingleSourceQuerySpec('query', { - * fieldRegistry: { - * filterable: [], - * sortable: ['created_at'], - * selectable: ['id', 'title', 'content'] - * }, - * disableFiltering: true - * }) - */ -export function createQuerySpec( - source: 'query' | 'json' | 'form', - configInput?: EndpointQueryConfig -) { - const config = EndpointQueryConfigSchema.parse(configInput) - - 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(), - }) - - // 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) -} - -// ============================================================================ -// HELPER FACTORY -// ============================================================================ - -/** - * Simplified factory for common case (query source only) - * - * Most endpoints only need to parse query parameters, so this provides - * a convenient shorthand. - * - * @param config Endpoint configuration - * @returns Schema for query-based input - * - * @example - * // Standard endpoint with all features - * const QuerySchema = createEndpointQuerySchema({ - * fieldRegistry: { - * filterable: ['status'], - * sortable: ['created_at', 'id'], - * selectable: ['id', 'title', 'status'] - * }, - * tiebreaker: 'id' - * }) - * - * @example - * // Fixed-output endpoint (disable field selection) - * const QuerySchema = createEndpointQuerySchema({ - * fieldRegistry: { - * filterable: ['status'], - * sortable: ['created_at', 'id'], - * selectable: [] // Not used when disabled - * }, - * disableFields: true, - * tiebreaker: 'id' - * }) - */ -export function createEndpointQuerySchema(config: EndpointQueryConfig) { - return createQuerySpec('query', config) -} diff --git a/edge/_shared/query/schemas.ts b/edge/_shared/query/schemas.ts deleted file mode 100644 index 29707ba..0000000 --- a/edge/_shared/query/schemas.ts +++ /dev/null @@ -1,410 +0,0 @@ -// utils/query/types.ts -/** - * Core types and schemas for query processing - * - * Provides: - * - Registry schemas for allowlists and validation - * - Normalized intermediary schemas - * - Zod schemas for all query features - * - QuerySpecSchema now has nullable filters and sorts for disable functionality - * - EndpointQueryConfig gains disableFiltering, disableSorting, disableFields flags - */ - -import { z } from 'zod' -import type { SupabaseClient } from '@supabase/supabase-js' -import type { Database } from '../types/database.ts' -import type { ValidationErrorDetail } from '../response/schemas.ts' - -// Re-export for convenience -export type { ValidationErrorDetail } - -// --------------------------------------------- -// Shared primitives -// --------------------------------------------- - -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 -// ============================================================================ - -/** - * Available filter operators - */ -export const FilterOperatorSchema = z.enum([ - 'eq', 'ne', - 'gt', 'gte', - 'lt', 'lte', - 'between', // NEW - 'in', 'nin', - 'contains', 'icontains', - 'startswith', 'endswith', - 'is_null', 'is_not_null' -]) - -export type FilterOperator = z.infer - -/** - * Normalized filter shape (output from adapters) - */ -export const FilterNormalizedSchema = z.object({ - field: NonEmptyStringSchema, - operator: FilterOperatorSchema, - value: z.unknown().optional() -}) - -export type FilterNormalized = z.infer - -/** - * Array of normalized filters - */ -export const FiltersNormalizedSchema = z.array(FilterNormalizedSchema) - -export type FiltersNormalized = z.infer - -/** - * Normalized filter shape (output from adapters) - */ -export const BaseFilterNormalizedSchema = z.object({ - field: NonEmptyStringSchema, - operator: FilterOperatorSchema.optional(), - value: z.unknown().optional() -}) - -export type BaseFilterNormalized = z.infer - -/** - * Array of normalized filters - */ -export const BaseFiltersNormalizedSchema = z.array(BaseFilterNormalizedSchema) - -export type BaseFiltersNormalized = z.infer - -/** - * `OperatorDefinitionSchema` - * - * Describes *per-field* filtering rules: - * - `operators`: which operators are allowed for this field (allowlist). - * - `type`: the scalar type used for value coercion (`string|number|boolean|date|enum|uuid`). - * - `values` (optional): enum allowlist for `type: 'enum'`. - * - `arrayOperators` (optional): which operators for this field *expect arrays*. - * - * Why `arrayOperators`? - * - Avoids hardcoding that `'in'`/`'nin'` (or future operators like `between`, `overlaps`, geo/json operators) - * always consume lists. Instead, each field opts into multi-value semantics explicitly, enabling - * field-specific policy, schema-driven validation, and accurate auto-docs/UI hints. - * - * How it interacts with URLs: - * - Bracket notation clients send comma-separated values (e.g., `filter[tag][in]=a,b,c`), which the validator - * converts to arrays *only if* the field’s `arrayOperators` includes that operator. JSON clients send arrays - * directly for `value`. Either way, normalization ends at the same `FiltersNormalized` shape. - */ -export const OperatorDefinitionSchema = z.object({ - operators: z.array(FilterOperatorSchema).min(1), - type: z.enum(['string', 'number', 'boolean', 'date', 'enum', 'uuid']), - values: z.array(z.string()).optional(), // For enum types - arrayOperators: z.array(FilterOperatorSchema).optional() // Operators that require arrays -}) - -/** - * See {@link OperatorDefinitionSchema} for full schema details. - */ -export type OperatorDefinition = z.infer - -/** - * Filter registry for a resource - */ -export const FilterRegistrySchema = z.record( - z.string(), - OperatorDefinitionSchema -) - -export type FilterRegistry = z.infer - -// ============================================================================ -// SORT SCHEMAS -// ============================================================================ - -/** - * Sort direction - */ -export const SortDirectionSchema = z.enum(['asc', 'desc']) - -export type SortDirection = z.infer - -/** - * Normalized sort shape (output from adapters) - */ -export const SortNormalizedSchema = z.object({ - field: NonEmptyStringSchema, - direction: SortDirectionSchema, - tiebreaker: z.boolean().default(false) -}) - -export type SortNormalized = z.infer - -/** - * Array of normalized sorts - */ -export const SortsNormalizedSchema = z.array(SortNormalizedSchema) - -export type SortsNormalized = z.infer - -// ============================================================================ -// FIELD SELECTION SCHEMAS -// ============================================================================ - -/** - * Simple field selection (fields=a,b,c) - */ -export const SimpleFieldSelectionSchema = z.object({ - type: z.literal('simple'), - fields: z.array(NonEmptyStringSchema).min(1) -}) - -/** - * JSON:API field selection (fields[type]=a,b,c) - */ -export const JsonApiFieldSelectionSchema = z.object({ - type: z.literal('jsonapi'), - fields: z.record(z.string(), z.array(NonEmptyStringSchema)) -}) - -/** - * Normalized field selection (output from adapters) - */ -export const FieldSelectionNormalizedSchema = z.discriminatedUnion('type', [ - SimpleFieldSelectionSchema, - JsonApiFieldSelectionSchema -]) - -export type FieldSelectionNormalized = z.infer - -// ============================================================================ -// PAGINATION SCHEMAS -// ============================================================================ - -/** - * CursorData schema - the validated payload inside a signed cursor token. - * - * This is what gets encoded into the cursor token and must be validated - * both on encode (before signing) and decode (after signature verification). - * - * sortValue accepts string | number | Date to handle various column types: - * - string: timestamps, text columns - * - number: numeric columns, integer IDs - * - Date: JS Date objects (serialized to ISO string) - */ -export const CursorDataSchema = z.object({ - /** Column name used for primary sort (e.g., 'created_at', 'score') */ - sortField: NonEmptyStringSchema, - /** Value of sortField from the boundary row */ - sortValue: z.union([ - z.string(), - z.number(), - z.coerce.date() - ]), - /** Column name used for tiebreaker (usually 'id') */ - tiebreaker: z.string(), - /** Value of tiebreaker from the boundary row */ - tiebreakerValue: z.union([z.string(), z.number()]), - /** Sort direction for this cursor */ - direction: SortDirectionSchema, - /** When this cursor was created (for TTL expiration) */ - createdAt: z.coerce.date() -}) - -export type CursorData = z.infer - -/** - * Offset pagination (normalized) - */ -export const OffsetPaginationNormalizedSchema = z.object({ - type: z.literal('offset'), - offset: z.number().int().min(0), - limit: z.number().int().positive(), -}) - -export type OffsetPaginationNormalized = z.infer - -/** - * 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(), - /** Decoded and verified cursor data (populated during validation, not parsing) */ - decodedCursor: CursorDataSchema.optional(), -}) - -export type CursorPaginationNormalized = z.infer - -/** - * Normalized pagination (union) - */ -export const PaginationNormalizedSchema = z.discriminatedUnion('type', [ - OffsetPaginationNormalizedSchema, - CursorPaginationNormalizedSchema -]) - -export type PaginationNormalized = z.infer - -// ============================================================================ -// QUERY SPEC SCHEMA -// ============================================================================ - -/** - * Pagination configuration - * - * All options for pagination in one place - */ -export const PaginationConfigSchema = z.object({ - limits: z.object({ - 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, - maxOffset: 1_000_000, - cursorTTL: 86400 - }), - cursorSecret: z.string().optional(), -}) - -export type PaginationConfig = z.infer - -/** - * Filters configuration - * - * All options for filtering in one place - */ -export const FiltersConfigSchema = z.object({ - registry: FilterRegistrySchema.optional(), - defaults: FiltersNormalizedSchema.optional(), - mergeDefaults: z.boolean().default(true), - disabled: z.boolean().default(false), - limits: z.object({ - maxFilters: z.number().int().positive().default(20), - }).default({ maxFilters: 20 }), -}) - -export type FiltersConfig = z.input - -/** - * Sorts configuration - * - * All options for sorting in one place - */ -export const SortsConfigSchema = z.object({ - tiebreaker: z.string().default('id'), - allowedFields: z.array(z.string()).optional(), - mergeDefaults: z.boolean().default(true), - defaults: SortsNormalizedSchema.optional(), - disabled: z.boolean().default(false), - limits: z.object({ - maxSorts: z.number().int().positive().default(5), - }).default({ maxSorts: 5 }), -}) - -export type SortsConfig = z.input - -/** - * Fields configuration - * - * All options for field selection in one place - */ -export const FieldsConfigSchema = z.object({ - allowedFields: z.array(z.string()).optional(), - defaults: z.array(z.string()).optional(), - disabled: z.boolean().default(false), - resourceType: z.string().optional(), // For JSON:API format -}).default({ disabled: false }) - -export type FieldsConfig = z.input - -/** - * Complete query specification (output from composite schema) - * - * This is the normalized, validated output that contains all query parameters - * ready to be applied to a database query. - */ -export const QuerySpecSchema = z.object({ - pagination: PaginationNormalizedSchema, - filters: FiltersNormalizedSchema.nullable(), - sorts: SortsNormalizedSchema.nullable(), - fields: FieldSelectionNormalizedSchema.nullable(), -}) - -export type QuerySpec = z.infer - -// ============================================================================ -// ENDPOINT CONFIGURATION (UPDATED) -// ============================================================================ - -/** - * Endpoint query configuration - * - * FIXED: All component configs are optional and have sensible defaults - * TypeScript types are clean (no | undefined noise) - * - * @example - * ```typescript - * // Minimal config - * const config = { - * tiebreaker: 'id' - * } - * - * // Full config - * const config = { - * tiebreaker: 'id', - * pagination: { - * limits: { defaultLimit: 50, maxLimit: 100 }, - * cursorSecret: CURSOR_SECRET - * }, - * filters: { - * registry: { ... }, - * defaults: [ ... ], - * mergeDefaults: true, - * disabled: false, - * limits: { maxFilters: 10 } - * }, - * sorts: { - * allowedFields: ['created_at', 'id'], - * defaults: [ ... ], - * disabled: false, - * limits: { maxSorts: 3 } - * }, - * fields: { - * allowedFields: ['id', 'title'], - * defaults: ['id'], - * disabled: false - * } - * } - * ``` - */ -export const EndpointQueryConfigSchema = z.object({ - pagination: PaginationConfigSchema, - filters: FiltersConfigSchema, - sorts: SortsConfigSchema, - fields: FieldsConfigSchema, -}) - -export type EndpointQueryConfig = z.input \ No newline at end of file diff --git a/edge/_shared/query/sorting.ts b/edge/_shared/query/sorting.ts deleted file mode 100644 index b8479cf..0000000 --- a/edge/_shared/query/sorting.ts +++ /dev/null @@ -1,307 +0,0 @@ -// utils/query/sorting.ts -/** - * Query sorting with multi-source support and validation - * - * Features: - * - Explicit direction: sort=field:direction,field:direction - * - Field allowlists via registry - * - Max sort count (DoS protection) - * - Always adds tiebreaker for deterministic ordering - * - Query/JSON/FormData source adapters - */ - -import { z } from 'zod' - -import { BaseQuerySchema, BaseJsonSchema, BaseFormSchema, ZStringOrStringArray } from '../server/schemas.ts' -import { - SortsNormalizedSchema, - type SortNormalized, - type SortsNormalized, - type SortDirection, - type SortsConfig, -} from './schemas.ts' - -// ============================================================================ -// WIRE SCHEMAS (raw incoming data) -// ============================================================================ - -/** - * Query parameter wire schema (raw incoming) - * Supports: ?sort=created_at:desc,id:asc - */ -export const SortsQueryWire = BaseQuerySchema.extend({ - sort: ZStringOrStringArray.optional() -}) - -/** - * JSON body wire schema (raw incoming) - * Expects: { sorts: [{ field: 'created_at', direction: 'desc' }] } - */ -export const SortsJsonWire = BaseJsonSchema.pipe( - z.object({ sorts: SortsNormalizedSchema.default([]) }) -) - -/** - * FormData wire schema (raw incoming) - */ -export const SortsFormWire = BaseFormSchema - -// ============================================================================ -// HELPER FUNCTIONS -// ============================================================================ - -/** - * Parse colon syntax from string - * Syntax: created_at:desc,id:asc - */ -function parseColonSyntax(data: string | undefined): SortsNormalized { - if (typeof data !== 'string' || !data?.trim()) return [] - - const sorts: SortNormalized[] = [] - const segments = data.split(',').map(s => s.trim()).filter(Boolean) - - for (const segment of segments) { - const [fieldRaw, dirRaw] = segment.split(':') - const field = (fieldRaw ?? '').trim() - const direction = (dirRaw ?? 'asc').trim().toLowerCase() - - if (!field) continue; - if (direction !== 'asc' && direction !== 'desc') continue; - - sorts.push({ - field: field, - direction: direction as SortDirection, - tiebreaker: false, - }) - } - - 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 (unidirectional - decode only) -// ============================================================================ - -/** - * 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, - SortsNormalizedSchema, - { - decode: (query): SortsNormalized => { - // Extract sort value (handle ZStringOrStringArray) - const raw = Array.isArray(query.sort) ? query.sort[0] : query.sort - return raw ? parseColonSyntax(raw) : [] - }, - encode: (sorts): z.input => { - const normalized = SortsNormalizedSchema.parse(sorts); - const encoded = encodeColonSyntax(normalized) - return encoded ? { sort: encoded } : {} - } - } - ) -} - -/** - * JSON body adapter (codec for round-trip testing) - * Expects: { sorts: [{ field: 'created_at', direction: 'desc' }] } - * - * @example - * const adapter = createSortsJsonAdapter() - * const normalized = adapter.decode({ - * sorts: [{ field: 'created_at', direction: 'desc' }] - * }) - */ -export function createSortsJsonAdapter() { - return z.codec( - SortsJsonWire, - SortsNormalizedSchema, - { - decode: (raw): SortsNormalized => { - return raw.sorts as SortsNormalized - }, - encode: (sorts): z.input => { - return { sorts: sorts } - } - } - ) -} - -/** - * FormData adapter (codec for round-trip testing) - * Supports: sort=created_at:desc,id:asc - * - * @example - * const adapter = createSortsFormAdapter() - * const formData = new FormData() - * formData.append('sort', 'created_at:desc,id:asc') - * const normalized = adapter.decode(formData) - */ -export function createSortsFormAdapter() { - return z.codec( - SortsFormWire, - SortsNormalizedSchema, - { - decode: (raw): SortsNormalized => { - const sortValue = raw.sort - if (!sortValue) return [] - - const sortStr = Array.isArray(sortValue) - ? String(sortValue[0]) - : String(sortValue) - - return parseColonSyntax(sortStr) - }, - encode: (sorts): z.input => { - const normalized = SortsNormalizedSchema.parse(sorts); - const encoded = encodeColonSyntax(normalized) - return encoded ? { sort: encoded } : {} - } - } - ) -} - -// ============================================================================ -// SCHEMA COMPOSITION WITH VALIDATION -// ============================================================================ - -/** - * Create endpoint-specific sorts schema with validation - * - * 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.defaults Default sort when none provided - * - * @example - * const schema = createSortsSchema({ - * source: 'query', - * allowedFields: ['created_at', 'title', 'id'], - * limits: { maxSorts: 3 }, - * tiebreaker: 'id', - * 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', tiebreaker: true }] - */ -export function createSortsSchema(config: { - source: 'query' | 'json' | 'form' -} & SortsConfig) { - 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 - // Step 1: Apply defaults - .transform((sorts) => { - // Use default if no sorts provided - if (sorts.length === 0 && config.defaults) { - return config.defaults - } - - // Merge defaults if enabled - if (config.mergeDefaults && config.defaults) { - const defaultSorts = config.defaults.filter( - def => !sorts.some(s => s.field === def.field) - ) - return [...defaultSorts, ...sorts] - } - - return sorts - }) - - // Step 2: Validate - .check((ctx) => { - const sorts = ctx.value - - // Check sort count before adding tiebreaker (DoS protection) - if (sorts.length > maxSorts) { - ctx.issues.push({ - code: "too_big", - maximum: maxSorts, - origin: 'array', - path: [], - message: `Too many sorts: max ${maxSorts}, got ${sorts.length}`, - input: sorts - }) - return // Don't continue - } - - // Validate allowed fields - if (allowSet.size > 0) { - sorts.forEach((sort, idx) => { - 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 - }) - } - }) - } - }) - - // 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) { - withTiebreaker.push({ - field: tiebreaker, - direction: 'asc' as SortDirection, - tiebreaker: true - }) - } - - return withTiebreaker - }) -} diff --git a/edge/_shared/response/errors.ts b/edge/_shared/response/errors.ts deleted file mode 100644 index c3d3066..0000000 --- a/edge/_shared/response/errors.ts +++ /dev/null @@ -1,636 +0,0 @@ -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://api.bundlejs.com/error' -export const BASE_DOCS_URL = 'https://docs.bundlejs.com/errors' - -/** Canonical problem type URIs. */ -export const ERROR_TYPES = { - BAD_REQUEST: `${BASE_ERROR_URL}/bad-request`, - UNAUTHORIZED: `${BASE_ERROR_URL}/unauthorized`, - FORBIDDEN: `${BASE_ERROR_URL}/forbidden`, - NOT_FOUND: `${BASE_ERROR_URL}/not-found`, - METHOD_NOT_ALLOWED: `${BASE_ERROR_URL}/method-not-allowed`, - NOT_ACCEPTABLE: `${BASE_ERROR_URL}/not-acceptable`, - REQUEST_TIMEOUT: `${BASE_ERROR_URL}/request-timeout`, - CONFLICT: `${BASE_ERROR_URL}/conflict`, - GONE: `${BASE_ERROR_URL}/gone`, - PRECONDITION_FAILED: `${BASE_ERROR_URL}/precondition-failed`, - PAYLOAD_TOO_LARGE: `${BASE_ERROR_URL}/payload-too-large`, - URI_TOO_LONG: `${BASE_ERROR_URL}/uri-too-long`, - UNSUPPORTED_MEDIA_TYPE: `${BASE_ERROR_URL}/unsupported-media-type`, - RANGE_NOT_SATISFIABLE: `${BASE_ERROR_URL}/range-not-satisfiable`, - UNPROCESSABLE_ENTITY: `${BASE_ERROR_URL}/unprocessable-entity`, - PRECONDITION_REQUIRED: `${BASE_ERROR_URL}/precondition-required`, - REQUEST_HEADER_FIELDS_TOO_LARGE: `${BASE_ERROR_URL}/request-header-fields-too-large`, - UNAVAILABLE_FOR_LEGAL_REASONS: `${BASE_ERROR_URL}/unavailable-for-legal-reasons`, - VALIDATION_ERROR: `${BASE_ERROR_URL}/validation-error`, - RATE_LIMIT_EXCEEDED: `${BASE_ERROR_URL}/rate-limit-exceeded`, - INTERNAL_SERVER_ERROR: `${BASE_ERROR_URL}/internal-server-error`, - NOT_IMPLEMENTED: `${BASE_ERROR_URL}/not-implemented`, - BAD_GATEWAY: `${BASE_ERROR_URL}/bad-gateway`, - SERVICE_UNAVAILABLE: `${BASE_ERROR_URL}/service-unavailable`, - GATEWAY_TIMEOUT: `${BASE_ERROR_URL}/gateway-timeout`, -} as const - -/** Canonical docs links for each problem type. */ -export const ERROR_DOCS = { - BAD_REQUEST: `${BASE_DOCS_URL}/bad-request`, - UNAUTHORIZED: `${BASE_DOCS_URL}/unauthorized`, - FORBIDDEN: `${BASE_DOCS_URL}/forbidden`, - NOT_FOUND: `${BASE_DOCS_URL}/not-found`, - METHOD_NOT_ALLOWED: `${BASE_DOCS_URL}/method-not-allowed`, - NOT_ACCEPTABLE: `${BASE_DOCS_URL}/not-acceptable`, - REQUEST_TIMEOUT: `${BASE_DOCS_URL}/request-timeout`, - CONFLICT: `${BASE_DOCS_URL}/conflict`, - GONE: `${BASE_DOCS_URL}/gone`, - PRECONDITION_FAILED: `${BASE_DOCS_URL}/precondition-failed`, - PAYLOAD_TOO_LARGE: `${BASE_DOCS_URL}/payload-too-large`, - URI_TOO_LONG: `${BASE_DOCS_URL}/uri-too-long`, - UNSUPPORTED_MEDIA_TYPE: `${BASE_DOCS_URL}/unsupported-media-type`, - RANGE_NOT_SATISFIABLE: `${BASE_DOCS_URL}/range-not-satisfiable`, - UNPROCESSABLE_ENTITY: `${BASE_DOCS_URL}/unprocessable-entity`, - PRECONDITION_REQUIRED: `${BASE_DOCS_URL}/precondition-required`, - REQUEST_HEADER_FIELDS_TOO_LARGE: `${BASE_DOCS_URL}/request-header-fields-too-large`, - UNAVAILABLE_FOR_LEGAL_REASONS: `${BASE_DOCS_URL}/unavailable-for-legal-reasons`, - VALIDATION_ERROR: `${BASE_DOCS_URL}/validation-error`, - RATE_LIMIT_EXCEEDED: `${BASE_DOCS_URL}/rate-limit-exceeded`, - INTERNAL_SERVER_ERROR: `${BASE_DOCS_URL}/internal-server-error`, - NOT_IMPLEMENTED: `${BASE_DOCS_URL}/not-implemented`, - BAD_GATEWAY: `${BASE_DOCS_URL}/bad-gateway`, - SERVICE_UNAVAILABLE: `${BASE_DOCS_URL}/service-unavailable`, - GATEWAY_TIMEOUT: `${BASE_DOCS_URL}/gateway-timeout`, -} as const - -/** Canonical human-readable titles by status. */ -export const STATUS_TITLES: Readonly> = { - 200: 'OK', - 201: 'Created', - 202: 'Accepted', - 204: 'No Content', - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 405: 'Method Not Allowed', - 406: 'Not Acceptable', - 408: 'Request Timeout', - 409: 'Conflict', - 410: 'Gone', - 412: 'Precondition Failed', - 413: 'Payload Too Large', - 414: 'URI Too Long', - 415: 'Unsupported Media Type', - 416: 'Range Not Satisfiable', - 422: 'Unprocessable Entity', - 428: 'Precondition Required', - 429: 'Too Many Requests', - 431: 'Request Header Fields Too Large', - 451: 'Unavailable For Legal Reasons', - 500: 'Internal Server Error', - 501: 'Not Implemented', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - 504: 'Gateway Timeout', -} - -export function titleFor(status: number): string { - return STATUS_TITLES[status] ?? 'Error' -} - -// ============================================================================ -// Status → convenience factory name map (symbolic) -// ============================================================================ - -export const STATUS_TO_FUNCTION_MAP = { - 400: 'badRequest', - 401: 'unauthorized', - 403: 'forbidden', - 404: 'notFound', - 405: 'methodNotAllowed', - 406: 'notAcceptable', - 408: 'requestTimeout', - 409: 'conflict', - 410: 'gone', - 412: 'preconditionFailed', - 413: 'payloadTooLarge', - 414: 'uriTooLong', - 415: 'unsupportedMediaType', - 416: 'rangeNotSatisfiable', - 422: 'unprocessableEntity', - 428: 'preconditionRequired', - 429: 'rateLimitExceeded', - 431: 'requestHeaderFieldsTooLarge', - 451: 'unavailableForLegalReasons', - 500: 'internalServerError', - 501: 'notImplemented', - 502: 'badGateway', - 503: 'serviceUnavailable', - 504: 'gatewayTimeout', -} as const - -export type KnownErrorStatus = keyof typeof STATUS_TO_FUNCTION_MAP - -// ============================================================================ -// Extensions (intent-forward extra fields) -// ============================================================================ - -export interface MethodNotAllowedExtension { allowed: string[] } -export interface UnsupportedMediaTypeExtension { supported?: string[] } -export interface PayloadTooLargeExtension { limitBytes?: number } -export interface RateLimitExtension { retryAfter: number } -export interface ServiceUnavailableExtension { service: string } - -// ============================================================================ -// Private base builder for problems (always uses STATUS_TITLES) -// ============================================================================ - -export function baseProblem( - status: ContentfulStatusCode, - type: string, - instance: string, - detail: string, - docs?: string, - extensions?: Record -): ErrorResult { - return [ - Object.assign( - { - type, - title: titleFor(status), - status, - detail, - instance, - timestamp: new Date(), - }, - docs ? { docs } : {}, - extensions ? extensions : {}, - ) as ProblemDetails, - status, - { 'Content-Type': 'application/problem+json' }, - ] as const -} - -// ============================================================================ -// Multi-error responses -// ============================================================================ - -/** - * Build RFC7807 with an `errors` array for multi-violations. - * - * @example - * const errors = [{ field: 'email', message: 'Invalid email' }] - * return c.json(...errs(ERROR_TYPES.UNPROCESSABLE_ENTITY, titleFor(422), 422, c.req.path, errors)) - */ -export function errs( - type: string, - title: string, - status: ContentfulStatusCode, - instance: string, - errors: ValidationErrorDetail[], - detail?: string, - docs?: string -): ErrorsResult { - const errorCount = errors.length - const defaultDetail = errorCount === 1 ? '1 error occurred' : `${errorCount} errors occurred` - return [ - Object.assign( - { - type, - title, - status, - detail: detail || defaultDetail, - instance, - timestamp: new Date(), - errors, - }, - docs ? { docs } : {}, - ) as ProblemDetailsWithErrors, - status, - { 'Content-Type': 'application/problem+json' }, - ] as const -} - -/** - * Canonical 422 validation failure shape with field-level details. - * - * @example - * return c.json(...validationFailed(c.req.path, toErrs(zodError.issues))) - */ -export function validationFailed( - instance: string, - errors: ValidationErrorDetail[], - detail?: string -): ErrorsResult { - const status = 422 as const - const errorCount = errors.length - const defaultDetail = - errorCount === 1 - ? '1 validation error occurred' - : `${errorCount} validation errors occurred` - - return errs( - ERROR_TYPES.VALIDATION_ERROR, - titleFor(status), - status, - instance, - errors, - detail || defaultDetail, - ERROR_DOCS.VALIDATION_ERROR - ) -} - - -// ============================================================================ -// Convenience error factories (titles always from STATUS_TITLES) -// ============================================================================ - -export function badRequest( - instance: string, - detail: string, - extensions?: Record -): ErrorResult { - return baseProblem(400, ERROR_TYPES.BAD_REQUEST, instance, detail, ERROR_DOCS.BAD_REQUEST, extensions) -} - -export function unauthorized( - instance: string, - detail: string = 'Authentication required', - extensions?: Record -): ErrorResult { - return baseProblem(401, ERROR_TYPES.UNAUTHORIZED, instance, detail, ERROR_DOCS.UNAUTHORIZED, extensions) -} - -export function forbidden( - instance: string, - detail: string = 'Insufficient permissions', - extensions?: Record -): ErrorResult { - return baseProblem(403, ERROR_TYPES.FORBIDDEN, instance, detail, ERROR_DOCS.FORBIDDEN, extensions) -} - -export function notFound( - instance: string, - detail: string = 'Resource not found', - extensions?: Record -): ErrorResult { - return baseProblem(404, ERROR_TYPES.NOT_FOUND, instance, detail, ERROR_DOCS.NOT_FOUND, extensions) -} - -export function methodNotAllowed( - instance: string, - allowed: MethodNotAllowedExtension['allowed'], - detail: string = 'Method not allowed', - extensions?: Record -): ErrorResult { - return baseProblem( - 405, - ERROR_TYPES.METHOD_NOT_ALLOWED, - instance, - detail, - ERROR_DOCS.METHOD_NOT_ALLOWED, - Object.assign({ allowed }, extensions ? extensions : {}), - ) -} - -export function notAcceptable( - instance: string, - detail: string = 'Not acceptable', - extensions?: Record -): ErrorResult { - return baseProblem(406, ERROR_TYPES.NOT_ACCEPTABLE, instance, detail, ERROR_DOCS.NOT_ACCEPTABLE, extensions) -} - -export function requestTimeout( - instance: string, - detail: string = 'Request timed out', - extensions?: Record -): ErrorResult { - return baseProblem(408, ERROR_TYPES.REQUEST_TIMEOUT, instance, detail, ERROR_DOCS.REQUEST_TIMEOUT, extensions) -} - -export function conflict( - instance: string, - detail: string = 'Resource already exists', - extensions?: Record -): ErrorResult { - return baseProblem(409, ERROR_TYPES.CONFLICT, instance, detail, ERROR_DOCS.CONFLICT, extensions) -} - -export function gone( - instance: string, - detail: string = 'Resource is gone', - extensions?: Record -): ErrorResult { - return baseProblem(410, ERROR_TYPES.GONE, instance, detail, ERROR_DOCS.GONE, extensions) -} - -export function preconditionFailed( - instance: string, - detail: string = 'Precondition failed', - extensions?: Record -): ErrorResult { - return baseProblem(412, ERROR_TYPES.PRECONDITION_FAILED, instance, detail, ERROR_DOCS.PRECONDITION_FAILED, extensions) -} - -export function payloadTooLarge( - instance: string, - limitBytes?: PayloadTooLargeExtension['limitBytes'], - detail: string = 'Payload too large', - extensions?: Record -): ErrorResult { - return baseProblem( - 413, - ERROR_TYPES.PAYLOAD_TOO_LARGE, - instance, - detail, - ERROR_DOCS.PAYLOAD_TOO_LARGE, - Object.assign(limitBytes ? { limitBytes } : {}, extensions ? extensions : {}), - ) -} - -export function uriTooLong( - instance: string, - detail: string = 'URI too long', - extensions?: Record -): ErrorResult { - return baseProblem(414, ERROR_TYPES.URI_TOO_LONG, instance, detail, ERROR_DOCS.URI_TOO_LONG, extensions) -} - -export function unsupportedMediaType( - instance: string, - supported?: UnsupportedMediaTypeExtension['supported'], - detail: string = 'Unsupported media type', - extensions?: Record -): ErrorResult { - return baseProblem( - 415, - ERROR_TYPES.UNSUPPORTED_MEDIA_TYPE, - instance, - detail, - ERROR_DOCS.UNSUPPORTED_MEDIA_TYPE, - Object.assign(supported ? { supported } : {}, extensions ? extensions : {}), - ) -} - -export function rangeNotSatisfiable( - instance: string, - detail: string = 'Range not satisfiable', - extensions?: Record -): ErrorResult { - return baseProblem(416, ERROR_TYPES.RANGE_NOT_SATISFIABLE, instance, detail, ERROR_DOCS.RANGE_NOT_SATISFIABLE, extensions) -} - -export function unprocessableEntity( - instance: string, - detail: string = 'Request contains semantic errors', - extensions?: Record -): ErrorResult { - return baseProblem(422, ERROR_TYPES.UNPROCESSABLE_ENTITY, instance, detail, ERROR_DOCS.UNPROCESSABLE_ENTITY, extensions) -} - -export function preconditionRequired( - instance: string, - detail: string = 'Precondition required', - extensions?: Record -): ErrorResult { - return baseProblem(428, ERROR_TYPES.PRECONDITION_REQUIRED, instance, detail, ERROR_DOCS.PRECONDITION_REQUIRED, extensions) -} - -export function rateLimitExceeded( - instance: string, - retryAfter: RateLimitExtension['retryAfter'], - extensions?: Record -): ErrorResult { - return baseProblem( - 429, - ERROR_TYPES.RATE_LIMIT_EXCEEDED, - instance, - `Rate limit exceeded. Retry after ${retryAfter} seconds.`, - ERROR_DOCS.RATE_LIMIT_EXCEEDED, - Object.assign({ retryAfter }, extensions ? extensions : {}), - ) -} - -export function requestHeaderFieldsTooLarge( - instance: string, - detail: string = 'Request header fields too large', - extensions?: Record -): ErrorResult { - return baseProblem( - 431, - ERROR_TYPES.REQUEST_HEADER_FIELDS_TOO_LARGE, - instance, - detail, - ERROR_DOCS.REQUEST_HEADER_FIELDS_TOO_LARGE, - extensions - ) -} - -export function unavailableForLegalReasons( - instance: string, - detail: string = 'Unavailable for legal reasons', - extensions?: Record -): ErrorResult { - return baseProblem( - 451, - ERROR_TYPES.UNAVAILABLE_FOR_LEGAL_REASONS, - instance, - detail, - ERROR_DOCS.UNAVAILABLE_FOR_LEGAL_REASONS, - extensions - ) -} - -export function internalServerError( - instance: string, - detail: string = 'Internal server error', - extensions?: Record -): ErrorResult { - return baseProblem(500, ERROR_TYPES.INTERNAL_SERVER_ERROR, instance, detail, ERROR_DOCS.INTERNAL_SERVER_ERROR, extensions) -} - -export function notImplemented( - instance: string, - detail: string = 'Not implemented', - extensions?: Record -): ErrorResult { - return baseProblem(501, ERROR_TYPES.NOT_IMPLEMENTED, instance, detail, ERROR_DOCS.NOT_IMPLEMENTED, extensions) -} - -export function badGateway( - instance: string, - detail: string = 'Bad gateway', - extensions?: Record -): ErrorResult { - return baseProblem(502, ERROR_TYPES.BAD_GATEWAY, instance, detail, ERROR_DOCS.BAD_GATEWAY, extensions) -} - -export function serviceUnavailable( - instance: string, - service: ServiceUnavailableExtension['service'], - detail?: string, - extensions?: Record -): ErrorResult { - return baseProblem( - 503, - ERROR_TYPES.SERVICE_UNAVAILABLE, - instance, - detail || `Upstream service '${service}' unavailable`, - ERROR_DOCS.SERVICE_UNAVAILABLE, - Object.assign({ service }, extensions ? extensions : {}), - ) -} - -export function gatewayTimeout( - instance: string, - detail: string = 'Gateway timeout', - extensions?: Record -): ErrorResult { - return baseProblem(504, ERROR_TYPES.GATEWAY_TIMEOUT, instance, detail, ERROR_DOCS.GATEWAY_TIMEOUT, extensions) -} - -// ============================================================================ -// err(): status-agnostic entrypoint with smart delegation (no `any` casting) -// ============================================================================ - -/** - * Build a Problem Details error for any status. - * - Delegates to the matching convenience factory (with required extras). - * - Falls back to a generic RFC7807 if status is not mapped. - */ -export function err( - status: ContentfulStatusCode, - instance: string, - detail: string, - extensions?: Record -): ErrorResult { - switch (status as number) { - case 400: return badRequest(instance, detail, extensions) - case 401: return unauthorized(instance, detail, extensions) - case 403: return forbidden(instance, detail, extensions) - case 404: return notFound(instance, detail, extensions) - case 405: { - // If caller has `allowed`, they should call methodNotAllowed directly. - return methodNotAllowed(instance, [], detail, extensions) - } - case 406: return notAcceptable(instance, detail, extensions) - case 408: return requestTimeout(instance, detail, extensions) - case 409: return conflict(instance, detail, extensions) - case 410: return gone(instance, detail, extensions) - case 412: return preconditionFailed(instance, detail, extensions) - case 413: return payloadTooLarge(instance, undefined, detail, extensions) - case 414: return uriTooLong(instance, detail, extensions) - case 415: return unsupportedMediaType(instance, undefined, detail, extensions) - case 416: return rangeNotSatisfiable(instance, detail, extensions) - case 422: return unprocessableEntity(instance, detail, extensions) - case 428: return preconditionRequired(instance, detail, extensions) - case 429: { - const retryAfter = - extensions && typeof (extensions as { retryAfter?: unknown }).retryAfter === 'number' - ? (extensions as { retryAfter: number }).retryAfter - : 60 - return rateLimitExceeded(instance, retryAfter, extensions) - } - case 431: return requestHeaderFieldsTooLarge(instance, detail, extensions) - case 451: return unavailableForLegalReasons(instance, detail, extensions) - case 500: return internalServerError(instance, detail, extensions) - case 501: return notImplemented(instance, detail, extensions) - case 502: return badGateway(instance, detail, extensions) - case 503: { - const service = - extensions && typeof (extensions as { service?: unknown }).service === 'string' - ? (extensions as { service: string }).service - : 'unknown' - return serviceUnavailable(instance, service, detail, extensions) - } - case 504: return gatewayTimeout(instance, detail, extensions) - default: - // Generic fallback uses canonical title & inferred type. - return baseProblem( - status, - `${BASE_ERROR_URL}/${String(status)}`, - instance, - detail, - undefined, - extensions - ) - } -} - -// ============================================================================ -// Header helpers (typed) -// ============================================================================ - -/** - * Compute extra HTTP headers that should accompany a Problem response. - * - 429/503: set `Retry-After` from `extensions.retryAfter` (seconds) when present. - */ -export function extraProblemHeaders( - status: ContentfulStatusCode, - extensions?: Record -): Record { - const out: Record = {} - const retryAfter = - extensions && typeof (extensions as { retryAfter?: unknown }).retryAfter === 'number' - ? (extensions as { retryAfter: number }).retryAfter - : undefined - - if ((status === 429 || status === 503) && typeof retryAfter === 'number') { - out['Retry-After'] = String(retryAfter) - } - return out -} - -/** - * Throws an RFC 7807–compliant HTTP problem response. - * - * This utility wraps an `ErrorResult` tuple into a Hono `HTTPException`, - * ensuring that validation errors or other structured error payloads - * are returned in a standardized JSON format. - * - * The response body follows the [RFC 7807 Problem Details for HTTP APIs](https://datatracker.ietf.org/doc/html/rfc7807) - * specification, allowing clients to consume consistent error information. - * - * @param err - A tuple containing: - * - `error`: The problem details object (e.g. validation errors). - * - `status`: The HTTP status code to return. - * - `headers`: Optional headers to include in the response. - * - * @throws {HTTPException} Always throws a Hono `HTTPException` with the given - * problem details, status code, and headers. - * - * @example - * ```ts - * // Example usage inside a Hono route: - * if (!isValid(input)) { - * exception([{ title: "Invalid input", detail: "Field X is required" }, 400, { "Content-Type": "application/problem+json" }]); - * } - * ``` - */ -export function exception(err: ErrorResponse) { - const [error, status, headers] = err; - - // Return RFC 7807 validation error array - return new HTTPException(status, { - res: new Response(JSON.stringify(error), { status, headers: headers }), - }); -} - -/** - * Check if response is an error by inspecting Content-Type - * Don't destructure before checking - this maintains type narrowing - * - * @example - * const result = await someApiCall() - * if (isErrorResponse(result)) { - * const [error, status, headers] = result // ✅ result is now ErrorResponse - * return c.json(error, status, headers) - * } - * // ✅ result is now SuccessResponse - * const [data, status, headers] = result - */ -export function isErrorResponse(response: ResponseResult): response is ErrorResponse { - return response[2]['Content-Type'] === 'application/problem+json' -} diff --git a/edge/_shared/response/mod.ts b/edge/_shared/response/mod.ts deleted file mode 100644 index 0b08e6a..0000000 --- a/edge/_shared/response/mod.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * RFC 7807 + Success Envelope Utilities for Hono - * ------------------------------------------------ - * - Canonical status titles & problem type/docs registries - * - Strict tuples for success & error responses (stable 3-tuple shape) - * - Convenience error factories for all common HTTP errors - * - Success helpers: ok, created (201 + Location), accepted (202), noContent (204), paginate - * - errs() + validationFailed() for multi-error payloads - * - Header utilities: extraProblemHeaders() and withHeaders() with precise typing - * - * Notes - * - Uses Object.assign for meta/header building to match your style preferences. - * - Avoids `any` in public types; no unsafe casts in user-facing APIs. - * - ESM-friendly, Deno v2 / TS strict, tree-shakeable. - */ - -export * from "./schemas.ts"; -export * from "./errors.ts"; -export * from "./success.ts"; -export * from "./status-codes.ts" - -// ============================================================================ -// Quick usage examples (copy/paste into handlers) -// ============================================================================ -// -// // OK -// return c.json(...ok({ id: 'like-1' })) -// -// // Created + Location -// const res = created({ id }, `/api/things/${id}`) -// return c.json(...withHeaders(res, { Location: `/api/things/${id}` })) -// -// // Rate limited with Retry-After header -// const err429 = rateLimitExceeded(c.req.path, 120) -// return c.json(...withHeaders(err429, extraProblemHeaders(429, { retryAfter: 120 }))) -// -// // Validation -// return c.json(...validationFailed(c.req.path, [{ field: 'email', message: 'Invalid' }])) -// -// // Status-agnostic -// return c.json(...err(404, c.req.path, 'Thing not found')) diff --git a/edge/_shared/response/schemas.ts b/edge/_shared/response/schemas.ts deleted file mode 100644 index 86c5cd9..0000000 --- a/edge/_shared/response/schemas.ts +++ /dev/null @@ -1,493 +0,0 @@ -// utils/response/schemas.ts -/** - * Response types and schemas following RFC 7807 (Problem Details) and success envelopes - * - * Architecture: - * - Everything is a Zod schema first - * - Types are inferred from schemas via z.infer - * - Factory functions create generic schemas (like in fields.ts, filtering.ts) - * - Follows same pattern as query processing utilities - * - Aligns with Hono's ContentfulStatusCode and ContentlessStatusCode types - */ - -import type { ContentfulStatusCode, ContentlessStatusCode } from 'hono/utils/http-status' -import type { RequestHeader } from 'hono/utils/headers' - -import { ContentfulStatusCodeSchema } from './status-codes.ts' -import { QuerySpecSchema } from '../query/schemas.ts' -import { z } from 'zod' - -// ============================================================================ -// BASE COMPONENT SCHEMAS -// ============================================================================ - -/** - * Validation error detail schema - * Individual field-level or rule-level validation error - */ -export const ValidationErrorDetailSchema = z.object({ - field: z.string(), - message: z.string() -}) - -export type ValidationErrorDetail = z.infer - -/** - * RFC 7807 Problem Details base schema - */ -export const ProblemDetailsSchema = z.object({ - type: z.string(), - title: z.string(), - status: ContentfulStatusCodeSchema, - detail: z.string(), - instance: z.string(), - timestamp: z.coerce.date(), - docs: z.url().optional() -}) - -export type ProblemDetails = z.infer - -/** - * RFC 7807 Problem Details with validation errors array - */ -export const ProblemDetailsWithErrorsSchema = ProblemDetailsSchema.extend({ - errors: z.array(ValidationErrorDetailSchema) -}) - -export type ProblemDetailsWithErrors = z.infer - -/** - * Metadata describing the pagination state of a result set. - * - * This interface is designed to support both cursor-based and offset-based - * pagination strategies. It captures the current slice of data and provides - * hints for navigating forward/backward. - * - * Supports cursor-based and offset-based pagination, plus - * optional approximate totals and expiry timestamps. - */ -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() -}) - -export type Pagination = z.infer - -/** - * Base metadata schema - accepts arbitrary additional fields - */ -export const DataMetadataSchema = z.object().catchall(z.unknown()) - -export type DataMetadata = z.infer - -/** - * LinkMap - * - * Normalized representation of RFC 8288-style links for a single response. - * - * - Keys are **link relation types** (`rel`): "self", "next", "prev", "first", "last", etc. - * - Values are URI references (often relative paths like `/articles?page=2`). - * - `self` is **required** and must be non-empty. - * - Standard pagination rels (`first`, `last`, `next`, `prev`) are optional. - * - Any additional rel (e.g. "describedby", "up", custom rels) is allowed via catchall. - * - * @example - * const links = LinkMapSchema.parse({ - * self: "/articles?offset=40&limit=20", - * first: "/articles?offset=0&limit=20", - * prev: "/articles?offset=20&limit=20", - * next: "/articles?offset=60&limit=20", - * last: "/articles?offset=120&limit=20", - * }) - * - * @example - * // With custom rels - * const links = LinkMapSchema.parse({ - * self: "/articles?offset=0&limit=20", - * describedby: "https://api.example.com/schemas/articles", - * profile: "https://api.example.com/profiles/pagination", - * }) - */ -export const LinkMapSchema = z - .object({ - self: z.string().min(1, "self link must be a non-empty URI reference"), - - // Common pagination link relations (all optional) - first: z.string().min(1).optional(), - last: z.string().min(1).optional(), - next: z.string().min(1).optional(), - prev: z.string().min(1).optional(), - }) - // Allow additional rels like "describedby", "up", "profile", etc. - .catchall(z.string().min(1)); - -export type LinkMap = z.infer; - -// ============================================================================ -// HEADER SCHEMAS -// ============================================================================ - -/** Standard Headers - properties are optional but must be strings when present */ -export const StandardHeadersSchema = z.object().catchall(z.string()); - -/** - * Standard JSON response headers - */ -export const JsonHeadersSchema = StandardHeadersSchema.extend({ - 'Content-Type': z.union([z.literal('application/json'), z.string()]) -}) - -export type JsonHeaders = z.infer - -/** - * Pagination response headers (includes RFC 8288 Link header) - */ -export const JsonHeadersWithLinksSchema = JsonHeadersSchema.extend({ - 'Link': z.string(), - 'X-Total-Count': z.string().optional(), - 'X-Per-Page': z.string().optional(), - 'X-Page': z.string().optional(), - 'X-Total-Pages': z.string().optional(), - 'Preference-Applied': z.string().optional(), - 'Range-Unit': z.string().optional(), - 'Content-Range': z.string().optional() -}) - -export type JsonHeadersWithLinks = z.infer - -/** - * RFC 7807 problem+json headers - */ -export const ProblemHeadersSchema = StandardHeadersSchema.extend({ - 'Content-Type': z.literal('application/problem+json') -}) - -export type ProblemHeaders = z.infer - -/** - * Generic standard headers type (for helper function signatures) - */ -export type StandardHeaders = { - [K in RequestHeader]?: string -} & z.infer - -// ============================================================================ -// ENVELOPE SCHEMA FACTORIES (for generic data types) -// ============================================================================ - -/** - * Create success envelope schema - * - * Structure: { data: T, meta: { timestamp: string, ...custom } } - * - * @example - * const UserSchema = z.object({ id: z.string(), name: z.string() }) - * const EnvelopeSchema = makeSuccessEnvelopeSchema(UserSchema) - * type Envelope = z.infer - * // => { data: User, meta: { timestamp: string } } - */ -export function makeSuccessEnvelopeSchema< - TData extends z.ZodType, - TMeta extends z.ZodObject = z.ZodObject<{}> ->( - dataSchema: TData, - metaSchema?: TMeta -) { - const baseMeta = z.object({ - timestamp: z.coerce.date(), - }) - - const meta = metaSchema - ? z.intersection(baseMeta, metaSchema) - : baseMeta - - return z.object({ - data: dataSchema, - meta: meta - }) -} - -/** - * Generic success envelope type (for helpers like ok(), created()) - * For actual validation, use makeSuccessEnvelopeSchema() - */ -export interface SuccessEnvelope { - data: T - meta: M & { timestamp: string } -} - - -/** - * Create pagination envelope schema - * - * Structure: { data: T[], meta: { timestamp: string, pagination: Pagination } } - * - * @example - * const UserSchema = z.object({ id: z.string(), name: z.string() }) - * const EnvelopeSchema = makePaginationEnvelopeSchema(z.array(UserSchema)) - * type Envelope = z.infer - */ -export function makePaginationEnvelopeSchema( - dataSchema: TData -) { - return makeSuccessEnvelopeSchema(dataSchema, PaginationMetadataSchema) -} - -/** - * Generic pagination envelope type (for helpers like paginate()) - * For actual validation, use makePaginationEnvelopeSchema() - */ -export type PaginationEnvelope = SuccessEnvelope - -/** - * Pagination metadata schema (pre-built) - */ -export const PaginationMetadataSchema = DataMetadataSchema.extend({ - pagination: PaginationSchema, - query: QuerySpecSchema.optional(), -}) - -export type PaginationMetadata = z.infer - -/** - * Create contentless result tuple schema - * For responses with no body (204, 101, 205, 304) - * - * @example - * const ResultSchema = makeContentlessResultSchema(204) - * type Result = z.infer - * // => readonly [undefined, 204, JsonHeaders] - */ -export const ContentlessResultSchema = z.tuple([ - z.undefined(), - ContentfulStatusCodeSchema, - JsonHeadersSchema -]) - -/** - * Generic contentless result tuple type - * For actual validation, use makeContentlessResultSchema() - */ -export type ContentlessResult = readonly [ - undefined, - ContentlessStatusCode, - JsonHeaders -] - -// ============================================================================ -// RESULT TUPLE SCHEMA FACTORIES -// ============================================================================ - -/** - * Create success result tuple schema - * For responses with data (200, 201, etc.) - * - * @example - * const UserSchema = z.object({ id: z.string() }) - * const ResultSchema = makeSuccessResultSchema(UserSchema, 200) - * type Result = z.infer - * // => readonly [{ data: User, meta: { timestamp: string } }, 200, JsonHeaders] - */ -export function makeSuccessResultSchema< - TData extends z.ZodType ->( - dataSchema: TData, - metaSchema?: z.ZodObject -) { - return z.tuple([ - makeSuccessEnvelopeSchema(dataSchema, metaSchema), - ContentfulStatusCodeSchema, - JsonHeadersSchema - ]) -} - -/** - * Generic success result tuple type - * For actual validation, use makeSuccessResultSchema() - */ -export type GenericSuccessResult = readonly [ - E, - ContentfulStatusCode, - H -] - -/** - * Success Result - */ -export type SuccessResult = GenericSuccessResult> - -/** - * Create pagination result tuple schema - * Always returns 200 with pagination metadata - * - * @example - * const UserSchema = z.object({ id: z.string() }) - * const ResultSchema = makePaginationResultSchema(z.array(UserSchema)) - * type Result = z.infer - * // => readonly [{ data: User[], meta: { timestamp: string, pagination: Pagination } }, 200, JsonHeadersWithLinks] - */ -export function makePaginationResultSchema( - dataSchema: TData -) { - return z.tuple([ - makePaginationEnvelopeSchema(dataSchema), - ContentfulStatusCodeSchema, - JsonHeadersWithLinksSchema - ]) -} - -/** - * Generic pagination result tuple type - * For actual validation, use makePaginationResultSchema() - */ -export type PaginationResult = GenericSuccessResult, JsonHeadersWithLinks> - -/** - * Create error result tuple schema - * For RFC 7807 error responses - * - * @example - * const ResultSchema = ErrorResultSchema - * type Result = z.infer - * // => readonly [ProblemDetails, 404, ProblemHeaders] - */ -export const ErrorResultSchema = z.tuple([ - ProblemDetailsSchema, - ContentfulStatusCodeSchema, - ProblemHeadersSchema -]) - -/** - * Generic error result tuple type - * For actual validation, use makeErrorResultSchema() - */ -export type ErrorResult = readonly [ - ProblemDetails, - ContentfulStatusCode, - ProblemHeaders -] - -/** - * Create validation errors result tuple schema - * For 422 responses with multiple field errors - * - * @example - * const ResultSchema = makeErrorsResultSchema() - * type Result = z.infer - * // => readonly [ProblemDetailsWithErrors, 422, ProblemHeaders] - */ -export const ErrorsResultSchema = z.tuple([ - ProblemDetailsWithErrorsSchema, - ContentfulStatusCodeSchema, - ProblemHeadersSchema -]) - -/** - * Generic errors result tuple type (with validation errors array) - * For actual validation, use makeErrorsResultSchema() - */ -export type ErrorsResult = readonly [ - ProblemDetailsWithErrors, - ContentfulStatusCode, - ProblemHeaders -] - -// ============================================================================ -// RESPONSE UNION SCHEMAS (for comprehensive validation) -// ============================================================================ - -/** - * Create success response union schema - * Includes contentless (204), success, and pagination results - * - * @example - * const UserSchema = z.object({ id: z.string() }) - * const ResponseSchema = makeSuccessResponseSchema(UserSchema) - * - * // Can validate any of: - * // - [{ data: User, meta }, 200, JsonHeaders] - * // - [{ data: User[], meta: { pagination } }, 200, JsonHeadersWithLinks] - * // - [undefined, 204, JsonHeaders] - */ -export function makeSuccessResponseSchema( - dataSchema: TData -) { - return z.union([ - makeSuccessResultSchema(dataSchema), - makePaginationResultSchema(dataSchema), - ContentlessResultSchema - ]) -} - -/** - * Union of success response types - */ -export type SuccessResponse = - | SuccessResult - | PaginationResult - | ContentlessResult - -/** - * Create error response union schema - * Includes common error status codes - * - * @example - * const ResponseSchema = ErrorResponseSchema - * - * // Can validate any of: - * // - [ProblemDetails, 400, ProblemHeaders] - * // - [ProblemDetails, 404, ProblemHeaders] - * // - [ProblemDetailsWithErrors, 422, ProblemHeaders] - * // - [ProblemDetails, 500, ProblemHeaders] - */ -export const ErrorResponseSchema = z.union([ - ErrorResultSchema, - ErrorsResultSchema -]) - -/** - * Union of error response types - */ -export type ErrorResponse = - | ErrorResult - | ErrorsResult - - -/** - * Create complete response union schema - * Includes all success and error variants - * - * @example - * const UserSchema = z.object({ id: z.string() }) - * const ResponseSchema = makeResponseResultSchema(UserSchema) - * - * // Can validate any response tuple from your API - */ -export function makeResponseResultSchema( - dataSchema: TData -) { - return z.union([ - makeSuccessResponseSchema(dataSchema), - ErrorResponseSchema, - ]) -} - -/** - * Union of all response tuple types - */ -export type ResponseResult = - | SuccessResponse - | ErrorResponse \ No newline at end of file diff --git a/edge/_shared/response/status-codes.ts b/edge/_shared/response/status-codes.ts deleted file mode 100644 index 25aafd6..0000000 --- a/edge/_shared/response/status-codes.ts +++ /dev/null @@ -1,210 +0,0 @@ -// utils/response/status-codes.ts -/** - * HTTP status-code schemas and types. - * - * Architecture & Conventions 🗺️ - * ──────────────────────────── - * – Every status-code *category* is expressed first as a **Zod schema** - * (literal-union of numeric codes). - * – The **TypeScript type** is inferred via `z.infer<…>` immediately - * after the schema. - * – Section headers mirror the layout used in **schemas.ts** to keep the - * codebase consistent and easy to scan. :contentReference[oaicite:0]{index=0} - */ - -import { z } from 'zod' - -/* ============================================================================ - * 1×× INFORMATIONAL STATUS CODES - * ============================================================================ - */ - -/** - * Informational (1××) status codes. - */ -export const InfoStatusCodeSchema = z.union([ - z.literal(100), - z.literal(101), - z.literal(102), - z.literal(103), -]) -export type InfoStatusCode = z.infer - -/* ============================================================================ - * 2×× SUCCESS STATUS CODES - * ============================================================================ - */ - -/** - * Successful (2××) status codes. - */ -export const SuccessStatusCodeSchema = z.union([ - z.literal(200), - z.literal(201), - z.literal(202), - z.literal(203), - z.literal(204), - z.literal(205), - z.literal(206), - z.literal(207), - z.literal(208), - z.literal(226), -]) -export type SuccessStatusCode = z.infer - -/* ============================================================================ - * 3×× REDIRECTION STATUS CODES - * (includes the historical 305 / 306 for completeness) - * ============================================================================ - */ - -/** - * Deprecated redirection status codes (305 & 306). - */ -export const DeprecatedStatusCodeSchema = z.union([ - z.literal(305), - z.literal(306), -]) -export type DeprecatedStatusCode = z.infer - -/** - * Redirection (3××) status codes. - */ -export const RedirectStatusCodeSchema = z.union([ - z.literal(300), - z.literal(301), - z.literal(302), - z.literal(303), - z.literal(304), - ...DeprecatedStatusCodeSchema.options, - z.literal(307), - z.literal(308), -]) -export type RedirectStatusCode = z.infer - -/* ============================================================================ - * 4×× CLIENT-ERROR STATUS CODES - * ============================================================================ - */ - -/** - * Client-error (4××) status codes. - */ -export const ClientErrorStatusCodeSchema = z.union([ - z.literal(400), - z.literal(401), - z.literal(402), - z.literal(403), - z.literal(404), - z.literal(405), - z.literal(406), - z.literal(407), - z.literal(408), - z.literal(409), - z.literal(410), - z.literal(411), - z.literal(412), - z.literal(413), - z.literal(414), - z.literal(415), - z.literal(416), - z.literal(417), - z.literal(418), - z.literal(421), - z.literal(422), - z.literal(423), - z.literal(424), - z.literal(425), - z.literal(426), - z.literal(428), - z.literal(429), - z.literal(431), - z.literal(451), -]) -export type ClientErrorStatusCode = z.infer - -/* ============================================================================ - * 5×× SERVER-ERROR STATUS CODES - * ============================================================================ - */ - -/** - * Server-error (5××) status codes. - */ -export const ServerErrorStatusCodeSchema = z.union([ - z.literal(500), - z.literal(501), - z.literal(502), - z.literal(503), - z.literal(504), - z.literal(505), - z.literal(506), - z.literal(507), - z.literal(508), - z.literal(510), - z.literal(511), -]) -export type ServerErrorStatusCode = z.infer - -/* ============================================================================ - * SPECIAL / UNOFFICIAL CODES - * ============================================================================ - */ - -/** - * Unofficial / “unknown” status code (-1). - * - * @example - * ```ts - * return c.text("Unknown Error", -1 as UnofficialStatusCode) - * ``` - */ -export const UnofficialStatusCodeSchema = z.literal(-1) -export type UnofficialStatusCode = z.infer - -/** - * @deprecated — Use `UnofficialStatusCode` instead. - */ -export type UnOfficalStatusCode = UnofficialStatusCode - -/* ============================================================================ - * AGGREGATE STATUS-CODE SCHEMAS - * ============================================================================ - */ - -/** - * Any legal HTTP status code (including unofficial). - */ -export const StatusCodeSchema = z.union([ - InfoStatusCodeSchema, - SuccessStatusCodeSchema, - RedirectStatusCodeSchema, - ClientErrorStatusCodeSchema, - ServerErrorStatusCodeSchema, - UnofficialStatusCodeSchema, -]) -export type StatusCode = z.infer - -/** - * Status codes that MUST NOT include a body in the response. - * (101 Switching Protocols · 204 No Content · 205 Reset Content · 304 Not Modified) - */ -export const ContentlessStatusCodeSchema = z.union([ - z.literal(101), - z.literal(204), - z.literal(205), - z.literal(304), -]) -export type ContentlessStatusCode = z.infer - -/** - * Status codes that MAY include a body in the response. - * Defined as *all* legal codes minus the four content-less ones. - */ -export const ContentfulStatusCodeSchema = StatusCodeSchema.refine( - (code): code is Exclude => - !ContentlessStatusCodeSchema.safeParse(code).success, - { error: 'Contentful status code expected' }, - ) -export type ContentfulStatusCode = z.infer - diff --git a/edge/_shared/response/success.ts b/edge/_shared/response/success.ts deleted file mode 100644 index 17b3823..0000000 --- a/edge/_shared/response/success.ts +++ /dev/null @@ -1,415 +0,0 @@ -import type { ContentlessStatusCode, ContentfulStatusCode, StatusCode } from 'hono/utils/http-status' -import type { ContentlessResult, Pagination, SuccessResult, PaginationResult, DataMetadata, JsonHeadersWithLinks, StandardHeaders, ResponseResult, SuccessResponse, SuccessEnvelope, GenericSuccessResult, PaginationMetadata, LinkMap } from './schemas.ts' - -/** - * 200/201/202/204 OK-style success envelope. - * - * @example - * return c.json(...ok({ id: 'like-1' })) // 200 - * return c.json(...ok(resource, 201)) // 201 Created (use `created` if you have a Location) - * return c.json(...ok(null, 204)) // 204 No Content - */ -export function ok( - data: T, - statusCode: ContentlessStatusCode, -): ContentlessResult; - -export function ok( - data: T, - statusCode?: ContentfulStatusCode, - meta?: M -): SuccessResult; - -export function ok( - data: T, - statusCode: StatusCode = 200, - meta = {} as M -): SuccessResult | ContentlessResult { - switch (statusCode) { - case 101: - case 204: - case 205: - case 304: - return [undefined, statusCode as ContentlessStatusCode, { 'Content-Type': 'application/json' }] as const - } - - return [ - { - data, - meta: Object.assign({ timestamp: new Date().toISOString() }, (meta ?? {})), - }, - statusCode as ContentfulStatusCode, - { 'Content-Type': 'application/json' }, - ] as const -} - -/** - * 201 Created — includes optional Location header. - * - * @example - * const res = created({ id }, `/api/orders/${id}`) - * return c.json(...withHeaders(res, { Location: `/api/orders/${id}` })) - */ -export function created( - data: T, - location?: string, - meta?: M -): SuccessResult { - const result = ok(data, 201, meta) - return location ? withHeaders(result, { Location: location }) : result -} - -/** - * 202 Accepted — server accepted the request for processing. - * - * Provide tracking metadata to help clients poll or subscribe. - * - * @example - * return c.json(...accepted({ taskId }, { tracking: { taskId, status: 'queued' } })) - */ -export function accepted( - data: T, - meta?: M -): SuccessResult { - return ok(data, 202, meta) -} - -/** - * 204 No Content — success with no payload. `data` is always null. - * - * @example - * return c.json(...noContent()) - */ -export function noContent(): ContentlessResult { - return ok(null, 204) -} - -/** - * Paginated success (cursor or offset) with standard + de-facto headers. - * - * Headers emitted: - * - Always: - * - Link: rel="next"/"prev" for cursor; rel="first"/"next"/"prev"/"last" for offset - * - Offset mode (when available): - * - X-Total-Count: - * - X-Per-Page: - * - X-Page: <1-based page index> - * - X-Total-Pages: // only when exact total known - * - Range-Unit: items // standards-aligned - * - Content-Range: start-end/total // only when exact total known - * - Preference-Applied: count=exact|estimated - * - * Notes: - * - We never guess "first/last" for cursor paging (that’s typically unstable). For offset we include both. - * - When only approxTotal is available, we still emit X-Total-Count (as best-effort) and mark - * Preference-Applied: count=estimated, but we avoid Content-Range and X-Total-Pages (those imply exactness). - * - * @example - * return c.json(...paginate(c.req.url, items, { hasMore: true, nextCursor: 'abc', limit: 20 })) - */ -export function paginate( - url = "/", - data: T, - pagination: Pagination -): PaginationResult { - // --- headers & links ------------------------------------------------------- - const headers: StandardHeaders = {} - const linkMap: LinkMap = { self: url }; - const linkHeaderParts: string[] = []; - - // Self link (current page) - if (pagination.offset !== undefined && pagination.limit) { - // Canonical offset-based URL for this page - const selfUrl = buildOffsetUrl(url, pagination.offset, pagination.limit); - linkHeaderParts.push(`<${selfUrl}>; rel="self"`) - linkMap.self = selfUrl - } else { - // Fallback: treat `path` as already representing the current URL (path + query) - linkHeaderParts.push(`<${url}>; rel="self"`) - linkMap.self = url - } - - // Cursor links - if (pagination.nextCursor) { - const nextUrl = buildCursorUrl(url, pagination.nextCursor, pagination.limit) - linkHeaderParts.push(`<${nextUrl}>; rel="next"`) - linkMap.next = nextUrl - } - if (pagination.prevCursor) { - const prevUrl = buildCursorUrl(url, pagination.prevCursor, pagination.limit) - linkHeaderParts.push(`<${prevUrl}>; rel="prev"`) - linkMap.prev = prevUrl - } - - // Offset links + extra headers - if (pagination.offset !== undefined && pagination.limit) { - 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); - - linkHeaderParts.push(`<${firstUrl}>; rel="first"`); - linkMap.first = firstUrl; - - // prev: only when not on first page - if (pagination.offset > 0) { - linkHeaderParts.push(`<${prevUrl}>; rel="prev"`); - linkMap.prev ??= prevUrl; - } - - // 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. - if (typeof total === "number" || typeof approx === "number") { - const totalCount = typeof total === "number" ? total : (approx as number) - 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" - } - - // Standards-aligned range headers (only when exact total is known) - // - Range-Unit: items - // - Content-Range: start-end/total (end is inclusive) - if (typeof total === "number") { - const start = Math.max(pagination.offset, 0) - const end = Math.max(Math.min(start + pagination.limit - 1, Math.max(total - 1, 0)), 0) - headers["Range-Unit"] = "items" - headers["Content-Range"] = `${start}-${end}/${total}` - } - - // Last link only when exact total is known (so it's meaningful) - 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"`); - linkMap.last = lastUrl; - } - } - - if (linkHeaderParts.length > 0) { - headers["Link"] = Array.from(new Set(linkHeaderParts)).join(", ").trim() - } - - // --- meta envelope --------------------------------------------------------- - const paginationMeta = Object.assign( - { - hasMore: pagination.hasMore, - limit: pagination.limit, - count: pagination.count, - }, - pagination.nextCursor !== undefined ? { nextCursor: pagination.nextCursor } : {}, - pagination.prevCursor !== undefined ? { prevCursor: pagination.prevCursor } : {}, - pagination.offset !== undefined ? { offset: pagination.offset } : {}, - pagination.total !== undefined ? { total: pagination.total } : {}, - (pagination.approxTotal !== undefined || pagination.total !== undefined) - ? { approxTotal: pagination.total ?? pagination.approxTotal } - : {}, - pagination.expiresAt !== undefined ? { expiresAt: pagination.expiresAt } : {}, - ) as Pagination; - - - const meta: PaginationMetadata = { pagination: paginationMeta, links: linkMap }; - - // Return typed pagination tuple with headers - return withHeaders(ok(data, 200, meta), headers as JsonHeadersWithLinks) satisfies PaginationResult -} - -/** - * Allowed values for query parameters. - * `null` and `undefined` are treated as "do not include". - */ -type QueryValue = string | number | boolean | null | undefined; - -/** - * Safely add or replace query params on a path or full URL. - * - * - Preserves existing query params - * - Works with relative paths ("/search?q=x") and full URLs ("https://example.com/search?q=x") - * - Preserves `#hash` fragments - * - * @example - * buildUrlWithParams("/search?q=superman", { cursor: "abc", limit: 20 }); - * // "/search?q=superman&cursor=abc&limit=20" - * - * @example - * buildUrlWithParams("https://example.com/list?offset=10", { offset: 30 }); - * // "https://example.com/list?offset=30" - */ -export function buildUrlWithParams( - path: string, - paramsToSet: Record, -): string { - // Split off hash fragment (if any) so we can re-attach it later - const [beforeHash, hash = ""] = path.split("#", 2); - // Split path and existing query string (if any) - const [base, existingQuery = ""] = beforeHash.split("?", 2); - - const searchParams = new URLSearchParams(existingQuery); - - // Apply new/updated params - for (const [key, value] of Object.entries(paramsToSet)) { - if (value === null || value === undefined) { - // Skip nullish values (could also choose to delete instead) - continue; - } - - let stringValue: string; - - if (typeof value === "boolean") { - stringValue = value ? "true" : "false"; - } else { - stringValue = String(value); - } - - searchParams.set(key, stringValue); - } - - const queryString = searchParams.toString(); - const hashPart = hash ? `#${hash}` : ""; - - if (queryString.length === 0) { - return `${base}${hashPart}`; - } - - return `${base}?${queryString}${hashPart}`; -} - -/** - * Helper to build cursor-based pagination URLs. - * - * - Safely adds/replaces `cursor` and `limit` - * - Preserves any existing query params - * - * @example - * buildCursorUrl("/search?q=superman", "abc123", 20); - * // "/search?q=superman&cursor=abc123&limit=20" - */ -export function buildCursorUrl( - path = "/", - cursor: string, - limit: number, -): string { - return buildUrlWithParams(path, { - cursor, - limit, - }); -} - -/** - * Helper to build offset-based pagination URLs. - * - * - Safely adds/replaces `offset` and `limit` - * - Preserves any existing query params - * - * @example - * buildOffsetUrl("/list?sort=desc", 40, 20); - * // "/list?sort=desc&offset=40&limit=20" - */ -export function buildOffsetUrl( - path: string, - offset: number, - limit: number, -): string { - return buildUrlWithParams(path, { - offset, - limit, - }); -} - -/** - * Merge extra headers into any 3-tuple response. - * - * Preserves exact body and status types, merges headers via intersection. - * Return type remains structurally compatible with ResponseResult members. - */ -export function withHeaders< - const T extends readonly [any, StatusCode, StandardHeaders], - const E extends StandardHeaders ->( - result: T, - extra: E -): readonly [T[0], T[1], T[2] & E] { - const [body, status, headers] = result - return [ - body, - status, - { ...headers, ...extra } as T[2] & E, - ] as const -} - -/** - * Merge extra metadata into the response envelope’s `meta` field. - * - * - Preserves `data` and status as-is - * - Deep-merges `meta` at the top level (shallow per key) - * - Keeps the `timestamp` from `ok()` intact - * - * @example - * const res = paginate(url, items, pagination) - * const enriched = withMeta(res, { - * query: { - * durationMs, - * filters, - * sorts, - * fields, - * source: { backend: 'supabase', adapter: 'query' }, - * }, - * }) - * return c.json(...enriched) - */ -export function withMeta< - const T extends GenericSuccessResult, - const M extends DataMetadata ->( - result: T, - extra: M -): readonly [ - SuccessEnvelope, - T[1], - T[2] -] { - const [body, status, headers] = result - - const mergedMeta = Object.assign( - {}, - body.meta ?? {}, - extra ?? {}, - ) - - return [ - Object.assign(body, { - data: body.data, - meta: mergedMeta as T[0]['meta'] & M, - }), - status, - headers, - ] as const -} - -/** - * Check if response is success - */ -export function isSuccessResponse(response: ResponseResult): response is SuccessResponse { - return response[2]['Content-Type'] !== 'application/problem+json' -} diff --git a/edge/_shared/server/create-app.ts b/edge/_shared/server/create-app.ts deleted file mode 100644 index c0452f1..0000000 --- a/edge/_shared/server/create-app.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * Hono App Factory for Supabase Edge Functions - * - * Creates a pre-configured Hono instance with: - * - Base path routing (function name prefix) - * - Global middleware (security headers, CORS, logging, etc.) - * - Request ID tracking for tracing - * - Timing middleware for performance monitoring - * - Standard health check endpoint - * - Type-safe context with app variables - * - RFC 7807 Problem Details error handling - */ - -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 { 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' -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 '../response/mod.ts' - -/** - * Context variables available throughout the request lifecycle. - * Extended by middleware to add typed values like authenticated Supabase client. - */ -export interface AppVariables extends - RequestIdVariables, - CorrelationVariables, - Partial, - Partial, - Partial, - Partial { - /** Release date for API versioning */ - releaseDate?: string - /** Resolved API version */ - apiVersion?: string -} - -/** - * Hono environment type with typed variables - */ -export interface AppEnv { - Variables: AppVariables -} - -/** - * Configuration options for creating a Hono app - */ -export interface CreateAppOptions { - /** - * Enable or configure CORS - * @default true (allows all origins) - */ - cors?: boolean | { - origin?: string | string[] - allowMethods?: string[] - allowHeaders?: string[] - exposeHeaders?: string[] - credentials?: boolean - maxAge?: number - } - - /** - * Enable request/response logging - * @default true - */ - logger?: boolean - - /** - * Enable pretty JSON formatting in responses - * @default true in development - */ - prettyJson?: boolean - - /** - * Enable security headers middleware - * @default true - */ - securityHeaders?: boolean - - /** - * Enable request timing middleware (sets x-response-time header) - * @default true - */ - timing?: boolean - - /** - * Enable automatic health check endpoint - * @default true - */ - healthCheck?: boolean - - /** - * Custom health check path - * @default '/health' - */ - healthCheckPath?: string - - /** - * Display all routes in verbose mode on startup - * @default false - */ - showRoutes?: boolean - - /** - * Service name to use for logging - */ - serviceName?: string -} - -/** - * Default configuration optimized for production - */ -const DEFAULT_OPTIONS: CreateAppOptions = { - cors: true, - logger: true, - prettyJson: true, - securityHeaders: true, - timing: true, - healthCheck: true, - healthCheckPath: '/health', - showRoutes: false, - 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. - * - * Middleware execution order (important!): - * 1. secureHeaders - Set security headers early - * 2. requestId - Generate request ID for tracing - * 3. cors - Handle CORS before auth - * 4. logger - Log all requests - * 5. timing - Measure request duration - * 6. prettyJSON - Format responses - * - * Handler-specific middleware (auth, validation) runs before route handlers. - * - * @param functionName - Name of the edge function (used as base path) - * @param options - Configuration options - * @returns Configured Hono app instance - * - * @example - * ```ts - * // Basic usage - * const app = createApp('likes'); - * - * // Custom configuration - * const app = createApp('auth', { - * cors: { - * origin: ['https://app.example.com'], - * credentials: true - * }, - * logger: true, - * healthCheckPath: '/healthz' - * }); - * ``` - */ -export function createApp( - functionName: string, - options: CreateAppOptions = {} -): Hono { - const config = { ...DEFAULT_OPTIONS, serviceName: functionName, ...options } - - // Create app with base path matching function name - const app = new Hono().basePath(`/${functionName}`) - - // 1. Security headers first (before everything else touches the response) - if (config.securityHeaders) { - app.use('*', secureHeaders()) - } - - // 2. Correlation & logging context (early, before handlers) - app.use('*', correlationMiddleware(config.serviceName)) - - // 3. Request ID (standard Hono) - app.use('*', requestId()) - - // 4. CORS (must be early, before auth) - if (config.cors) { - const corsConfig = typeof config.cors === 'boolean' - ? { origin: '*' } - : Object.assign({ origin: '*' }, config.cors) - - app.use('*', cors(corsConfig)) - } - - // 5. LogTape HTTP request logging (replaces hono/logger) - if (config.logger) { - 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) - if (config.timing) { - app.use('*', timing()) - } - - // 7. Pretty JSON formatting - if (config.prettyJson) { - app.use('*', prettyJSON()) - } - - // Health check endpoint - if (config.healthCheck) { - const healthPath = config.healthCheckPath || '/health' - - app.get(healthPath, (c) => { - return c.json({ - status: 'ok', - service: functionName, - timestamp: new Date().toISOString(), - uptime: performance.now(), - }) - }) - - // Support OPTIONS for CORS preflight - app.options(healthPath, (c) => c.text('', 204 as ContentfulStatusCode)) - } - - // Global error handler (catches HTTPException and other errors) - app.onError((_err, c) => { - const logger = getLogger(c) - - // HTTPException with pre-formatted RFC 7807 response - if (_err instanceof HTTPException && _err.res) { - return _err.res - } - - // HTTPException with status/message but no response - if (_err instanceof HTTPException) { - const status = _err.status - 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: {message}', { - error_type: _err?.constructor?.name, - message: _err instanceof Error ? _err.message : 'Unknown error', - stack: _err instanceof Error ? _err.stack : undefined, - }) - - return c.json(...internalServerError( - c.req.path, - 'Internal server error' - )) - }) - - // Display routes on startup - if (config.showRoutes) { - showRoutes(app, { - verbose: true, - }) - } - - return app -} - -/** - * Type helper for extracting app type - */ -export type AppType> = T \ No newline at end of file diff --git a/edge/_shared/server/schemas.ts b/edge/_shared/server/schemas.ts deleted file mode 100644 index 5c2dac0..0000000 --- a/edge/_shared/server/schemas.ts +++ /dev/null @@ -1,133 +0,0 @@ -// schemas.ts -import type { FormValue, ParsedFormValue } from 'hono/types'; -import type { EndpointDefinitionSchemas } from './types.ts'; -import { z } from 'zod'; - -/** - * Zod "string | string[]" union (mirrors Hono's `query` values). - * Useful for normalizing query params that may be repeated. - */ -export const ZStringOrStringArray = z.union([z.string(), z.array(z.string())]); - -/** - * ### Base JSON Schema - * - * - Accepts any JSON-like value. - * - Tighten per-endpoint with `.refine(...)`, `.transform(...)`, or `.pipe(...)`. - */ -export const BaseJsonSchema = z.any() satisfies NonNullable; - -/** - * ### Base Form Schema - * - * - **Input:** `Record`, where `T` defaults to `ParsedFormValue` (`string | File`). - * - Uses `z.object({}).catchall(...)` so it remains a **ZodObject** (giving you `.shape` and `.extend(...)`). - * - Extend per endpoint to restrict keys, change accepted types, or add transforms. - * - * @example - * // Restrict to a specific form key and coerce a file name: - * const UploadForm = makeBaseFormSchema().extend({ - * file: z.custom(), - * note: z.union([z.string(), z.array(z.string())]).transform(v => Array.isArray(v) ? v[0] ?? '' : v), - * }); - */ -export function makeBaseFormSchema() { - const value = z.union([z.custom(), z.array(z.custom())]); - return z.object({}).catchall(value) satisfies NonNullable['Form']>; -} - -/** - * @see {@link makeBaseFormSchema} - */ -export const BaseFormSchema = makeBaseFormSchema(); - -/** - * ### Base Query Schema - * - * - **Input:** `Record`. - * - Accepts arbitrary keys with `string | string[]` values. - * - Extend per endpoint with strongly-typed keys and transforms. - * - * @example - * const Query = BaseQuerySchema.extend({ - * page: z.coerce.number().int().min(1).default(1), - * sort: ZStringOrStringArray.transform(v => Array.isArray(v) ? v[0] ?? '' : v), - * }); - */ -export const BaseQuerySchema = z - .object({}) - .catchall(ZStringOrStringArray) satisfies NonNullable; - -/** - * ### Base Header Schema (extendable; no transforms) - * - * - **Input:** `Record` - * - Kept as a `ZodObject` so you can `.extend(Other.shape)` or spread shapes. - * - Apply normalization via `makeHeaderSchema(...)` when you’re done composing. - */ -export const BaseHeaderSchema = z - .object({}) - .catchall(z.string()) satisfies NonNullable; - -/** - * ### makeHeaderSchema - * - * Finalize a header schema by applying the **key-normalization transform** (lowercase keys). - * - * - Accepts any **object** schema compatible with the `header` input target - * (e.g., `BaseHeaderSchema`, or an extended version of it). - * - Returns a **piped** schema (`ZodPipe`) whose **output** is `Record` with - * lowercase keys. This is what you should actually **parse** with at the boundary. - * - * @example - * // 1) Compose while the schema is still a ZodObject - * const StrictHeader = BaseHeaderSchema.extend({ - * authorization: z.string().min(1), - * }); - * - * // 2) Only at the end, finalize with the transform - * const Header = makeHeaderSchema(StrictHeader); - * - * // Now parse incoming headers - * const parsed = Header.parse(reqHeaders); - * - * @example - * // If you don't need to customize headers, just: - * const Header = makeHeaderSchema(); // uses BaseHeaderSchema by default - */ -export function makeHeaderSchema< - // Infer the Input side from whatever object schema you pass in - S extends typeof BaseHeaderSchema ->(schema?: S) { - const base = (schema ?? BaseHeaderSchema) as S; - // Apply the transform at the very end; result is a ZodPipe (no .shape/.extend) - return base.transform((rec) => { - type K = (keyof typeof rec); - const out: Record = Object.create(null); - for (const k in rec) { - if (typeof k === "string") { - out[(k as string).toLowerCase()] = rec[k]; - } - } - return out; - }); -} - -/** - * ### Base Cookie Schema - * - * - **Input:** `Record`. - * - Simple string map; extend per endpoint to constrain or transform specific cookie keys. - */ -export const BaseCookieSchema = z - .object({}) - .catchall(z.string()) satisfies NonNullable; - - -/** - * ### Base Params Schema - */ -export const BaseParamSchema = z - .object({}) - .catchall(z.string()) satisfies NonNullable - diff --git a/edge/_shared/server/types.ts b/edge/_shared/server/types.ts deleted file mode 100644 index aaa6262..0000000 --- a/edge/_shared/server/types.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { AppEnv as SharedAppEnv } from '../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' - -/** - * 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. - * Extends the shared AppEnv and narrows Variables to specific middleware guarantees. - */ -export interface FunctionAppEnv extends SharedAppEnv { - Variables: SharedAppEnv['Variables'] & Variables -} - -export type BuildInput = {}> = Input & { - in: { - [K in keyof Schemas as Lowercase]: - K extends keyof Schemas ? z.input : never - } - out: { - [K in keyof Schemas as Lowercase]: - K extends keyof Schemas ? z.output : never - } -} - -export interface EndpointHandler< - 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 Handler { } - -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. - * - * Handlers may have different middleware requirements (and thus different Env types). - * We accept any middleware handler array without enforcing Env compatibility, - * since Hono can handle handlers with compatible-but-different env types at runtime. - * - * The contravariance of middleware makes it impossible to enforce strict typing here - * while still allowing different handlers with different variable requirements. - */ -export interface EndpointHandlerModule { - Middleware?: EndpointMiddlewareHandler[], - default: EndpointHandler -} \ No newline at end of file diff --git a/edge/_shared/types/database.ts b/edge/_shared/types/database.ts deleted file mode 100644 index 742f26b..0000000 --- a/edge/_shared/types/database.ts +++ /dev/null @@ -1,3579 +0,0 @@ -export type Json = - | string - | number - | boolean - | null - | { [key: string]: Json | undefined } - | Json[] - -export type Database = { - graphql_public: { - Tables: { - [_ in never]: never - } - Views: { - [_ in never]: never - } - Functions: { - graphql: { - Args: { - extensions?: Json - operationName?: string - query?: string - variables?: Json - } - Returns: Json - } - } - Enums: { - [_ in never]: never - } - CompositeTypes: { - [_ in never]: never - } - } - public: { - Tables: { - attribute_options: { - Row: { - attribute_id: string - created_at: string | null - display_value: string - hex_color: string | null - id: string - sort_order: number | null - value: string - } - Insert: { - attribute_id: string - created_at?: string | null - display_value: string - hex_color?: string | null - id?: string - sort_order?: number | null - value: string - } - Update: { - attribute_id?: string - created_at?: string | null - display_value?: string - hex_color?: string | null - id?: string - sort_order?: number | null - value?: string - } - Relationships: [ - { - foreignKeyName: "attribute_options_attribute_fk" - columns: ["attribute_id"] - isOneToOne: false - referencedRelation: "attributes" - referencedColumns: ["id"] - }, - ] - } - attributes: { - Row: { - applicable_to_mediums: Json | null - code: string - created_at: string | null - description: string | null - display_name: string - id: string - input_type: string - } - Insert: { - applicable_to_mediums?: Json | null - code: string - created_at?: string | null - description?: string | null - display_name: string - id?: string - input_type: string - } - Update: { - applicable_to_mediums?: Json | null - code?: string - created_at?: string | null - description?: string | null - display_name?: string - id?: string - input_type?: string - } - Relationships: [] - } - cart_items: { - Row: { - added_at: string | null - cart_id: string - id: string - price_cents: number - quantity: number - sku_id: string - updated_at: string | null - } - Insert: { - added_at?: string | null - cart_id: string - id?: string - price_cents: number - quantity: number - sku_id: string - updated_at?: string | null - } - Update: { - added_at?: string | null - cart_id?: string - id?: string - price_cents?: number - quantity?: number - sku_id?: string - updated_at?: string | null - } - Relationships: [ - { - foreignKeyName: "cart_items_cart_fk" - columns: ["cart_id"] - isOneToOne: false - referencedRelation: "carts" - referencedColumns: ["id"] - }, - { - foreignKeyName: "cart_items_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "in_stock_skus" - referencedColumns: ["id"] - }, - { - foreignKeyName: "cart_items_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "skus" - referencedColumns: ["id"] - }, - ] - } - carts: { - Row: { - abandoned_at: string | null - converted_to_order_id: string | null - created_at: string | null - id: string - last_activity_at: string | null - session_id: string | null - updated_at: string | null - user_id: string | null - version: number | null - } - Insert: { - abandoned_at?: string | null - converted_to_order_id?: string | null - created_at?: string | null - id?: string - last_activity_at?: string | null - session_id?: string | null - updated_at?: string | null - user_id?: string | null - version?: number | null - } - Update: { - abandoned_at?: string | null - converted_to_order_id?: string | null - created_at?: string | null - id?: string - last_activity_at?: string | null - session_id?: string | null - updated_at?: string | null - user_id?: string | null - version?: number | null - } - Relationships: [] - } - character_appearances: { - Row: { - character_uri: string - created_at: string | null - id: string - page_count: number | null - panel_count: number | null - persona: string | null - product_id: string - role_type: string - story_id: string | null - } - Insert: { - character_uri: string - created_at?: string | null - id?: string - page_count?: number | null - panel_count?: number | null - persona?: string | null - product_id: string - role_type: string - story_id?: string | null - } - Update: { - character_uri?: string - created_at?: string | null - id?: string - page_count?: number | null - panel_count?: number | null - persona?: string | null - product_id?: string - role_type?: string - story_id?: string | null - } - Relationships: [ - { - foreignKeyName: "character_appearances_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "available_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "character_appearances_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "character_appearances_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products_with_stats" - referencedColumns: ["id"] - }, - { - foreignKeyName: "character_appearances_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_owned_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "character_appearances_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_wishlist" - referencedColumns: ["id"] - }, - { - foreignKeyName: "character_appearances_role_fk" - columns: ["role_type"] - isOneToOne: false - referencedRelation: "character_role_types" - referencedColumns: ["code"] - }, - ] - } - character_role_types: { - Row: { - code: string - created_at: string | null - description: string | null - display_name: string - include_in_stats_by_default: boolean | null - sort_order: number | null - } - Insert: { - code: string - created_at?: string | null - description?: string | null - display_name: string - include_in_stats_by_default?: boolean | null - sort_order?: number | null - } - Update: { - code?: string - created_at?: string | null - description?: string | null - display_name?: string - include_in_stats_by_default?: boolean | null - sort_order?: number | null - } - Relationships: [] - } - collection_history: { - Row: { - action: string - changed_by_user_id: string - collection_id: string - created_at: string | null - id: string - notes: string | null - snapshot: Json | null - } - Insert: { - action: string - changed_by_user_id: string - collection_id: string - created_at?: string | null - id?: string - notes?: string | null - snapshot?: Json | null - } - Update: { - action?: string - changed_by_user_id?: string - collection_id?: string - created_at?: string | null - id?: string - notes?: string | null - snapshot?: Json | null - } - Relationships: [ - { - foreignKeyName: "collection_history_collection_fk" - columns: ["collection_id"] - isOneToOne: false - referencedRelation: "active_collections" - referencedColumns: ["id"] - }, - { - foreignKeyName: "collection_history_collection_fk" - columns: ["collection_id"] - isOneToOne: false - referencedRelation: "collections" - referencedColumns: ["id"] - }, - { - foreignKeyName: "collection_history_collection_fk" - columns: ["collection_id"] - isOneToOne: false - referencedRelation: "collections_with_counts" - referencedColumns: ["id"] - }, - { - foreignKeyName: "collection_history_collection_fk" - columns: ["collection_id"] - isOneToOne: false - referencedRelation: "user_collections" - referencedColumns: ["id"] - }, - ] - } - collection_items: { - Row: { - added_by_user_id: string | null - collection_id: string - created_at: string | null - id: string - item_id: string - item_type_code: string - notes: string | null - sort_order: number | null - } - Insert: { - added_by_user_id?: string | null - collection_id: string - created_at?: string | null - id?: string - item_id: string - item_type_code: string - notes?: string | null - sort_order?: number | null - } - Update: { - added_by_user_id?: string | null - collection_id?: string - created_at?: string | null - id?: string - item_id?: string - item_type_code?: string - notes?: string | null - sort_order?: number | null - } - Relationships: [ - { - foreignKeyName: "collection_items_collection_fk" - columns: ["collection_id"] - isOneToOne: false - referencedRelation: "active_collections" - referencedColumns: ["id"] - }, - { - foreignKeyName: "collection_items_collection_fk" - columns: ["collection_id"] - isOneToOne: false - referencedRelation: "collections" - referencedColumns: ["id"] - }, - { - foreignKeyName: "collection_items_collection_fk" - columns: ["collection_id"] - isOneToOne: false - referencedRelation: "collections_with_counts" - referencedColumns: ["id"] - }, - { - foreignKeyName: "collection_items_collection_fk" - columns: ["collection_id"] - isOneToOne: false - referencedRelation: "user_collections" - referencedColumns: ["id"] - }, - { - foreignKeyName: "collection_items_entity_type_fk" - columns: ["item_type_code"] - isOneToOne: false - referencedRelation: "entity_types" - referencedColumns: ["code"] - }, - ] - } - collection_visibilities: { - Row: { - code: string - created_at: string | null - description: string | null - display_name: string - is_public: boolean - sort_order: number - } - Insert: { - code: string - created_at?: string | null - description?: string | null - display_name: string - is_public?: boolean - sort_order?: number - } - Update: { - code?: string - created_at?: string | null - description?: string | null - display_name?: string - is_public?: boolean - sort_order?: number - } - Relationships: [] - } - collections: { - Row: { - collaboration_enabled_at: string | null - collection_type: string | null - cover_image_url: string | null - created_at: string | null - deleted_at: string | null - deleted_by_user_id: string | null - description: string | null - follower_count: number | null - id: string - item_count: number | null - like_count: number | null - name: string - owner_id: string - slug: string - sort_order: string | null - updated_at: string | null - visibility_code: string - } - Insert: { - collaboration_enabled_at?: string | null - collection_type?: string | null - cover_image_url?: string | null - created_at?: string | null - deleted_at?: string | null - deleted_by_user_id?: string | null - description?: string | null - follower_count?: number | null - id?: string - item_count?: number | null - like_count?: number | null - name: string - owner_id: string - slug: string - sort_order?: string | null - updated_at?: string | null - visibility_code?: string - } - Update: { - collaboration_enabled_at?: string | null - collection_type?: string | null - cover_image_url?: string | null - created_at?: string | null - deleted_at?: string | null - deleted_by_user_id?: string | null - description?: string | null - follower_count?: number | null - id?: string - item_count?: number | null - like_count?: number | null - name?: string - owner_id?: string - slug?: string - sort_order?: string | null - updated_at?: string | null - visibility_code?: string - } - Relationships: [ - { - foreignKeyName: "collections_visibility_fk" - columns: ["visibility_code"] - isOneToOne: false - referencedRelation: "collection_visibilities" - referencedColumns: ["code"] - }, - ] - } - comments: { - Row: { - content: string - content_html: string | null - created_at: string | null - deleted_at: string | null - depth: number | null - edited_at: string | null - id: string - like_count: number | null - moderated_at: string | null - moderated_by_user_id: string | null - moderation_reason: string | null - moderation_status_code: string - parent_comment_id: string | null - reply_count: number | null - root_comment_id: string | null - target_id: string - target_type_code: string - updated_at: string | null - user_id: string - } - Insert: { - content: string - content_html?: string | null - created_at?: string | null - deleted_at?: string | null - depth?: number | null - edited_at?: string | null - id?: string - like_count?: number | null - moderated_at?: string | null - moderated_by_user_id?: string | null - moderation_reason?: string | null - moderation_status_code?: string - parent_comment_id?: string | null - reply_count?: number | null - root_comment_id?: string | null - target_id: string - target_type_code: string - updated_at?: string | null - user_id: string - } - Update: { - content?: string - content_html?: string | null - created_at?: string | null - deleted_at?: string | null - depth?: number | null - edited_at?: string | null - id?: string - like_count?: number | null - moderated_at?: string | null - moderated_by_user_id?: string | null - moderation_reason?: string | null - moderation_status_code?: string - parent_comment_id?: string | null - reply_count?: number | null - root_comment_id?: string | null - target_id?: string - target_type_code?: string - updated_at?: string | null - user_id?: string - } - Relationships: [ - { - foreignKeyName: "comments_entity_type_fk" - columns: ["target_type_code"] - isOneToOne: false - referencedRelation: "entity_types" - referencedColumns: ["code"] - }, - { - foreignKeyName: "comments_moderation_fk" - columns: ["moderation_status_code"] - isOneToOne: false - referencedRelation: "moderation_statuses" - referencedColumns: ["code"] - }, - { - foreignKeyName: "comments_parent_fk" - columns: ["parent_comment_id"] - isOneToOne: false - referencedRelation: "active_comments_with_replies" - referencedColumns: ["id"] - }, - { - foreignKeyName: "comments_parent_fk" - columns: ["parent_comment_id"] - isOneToOne: false - referencedRelation: "comments" - referencedColumns: ["id"] - }, - { - foreignKeyName: "comments_root_fk" - columns: ["root_comment_id"] - isOneToOne: false - referencedRelation: "active_comments_with_replies" - referencedColumns: ["id"] - }, - { - foreignKeyName: "comments_root_fk" - columns: ["root_comment_id"] - isOneToOne: false - referencedRelation: "comments" - referencedColumns: ["id"] - }, - ] - } - consumption_statuses: { - Row: { - code: string - created_at: string | null - description: string | null - display_name: string - implies_ownership: boolean - sort_order: number - } - Insert: { - code: string - created_at?: string | null - description?: string | null - display_name: string - implies_ownership?: boolean - sort_order?: number - } - Update: { - code?: string - created_at?: string | null - description?: string | null - display_name?: string - implies_ownership?: boolean - sort_order?: number - } - Relationships: [] - } - entity_types: { - Row: { - code: string - created_at: string | null - description: string | null - display_name: string - is_neptune_entity: boolean - ontology_class_iri: string | null - sort_order: number - } - Insert: { - code: string - created_at?: string | null - description?: string | null - display_name: string - is_neptune_entity?: boolean - ontology_class_iri?: string | null - sort_order?: number - } - Update: { - code?: string - created_at?: string | null - description?: string | null - display_name?: string - is_neptune_entity?: boolean - ontology_class_iri?: string | null - sort_order?: number - } - Relationships: [] - } - inventory_reservations: { - Row: { - cart_id: string | null - created_at: string | null - id: string - order_id: string | null - quantity: number - released_at: string | null - reserved_until: string - sku_id: string - } - Insert: { - cart_id?: string | null - created_at?: string | null - id?: string - order_id?: string | null - quantity: number - released_at?: string | null - reserved_until: string - sku_id: string - } - Update: { - cart_id?: string | null - created_at?: string | null - id?: string - order_id?: string | null - quantity?: number - released_at?: string | null - reserved_until?: string - sku_id?: string - } - Relationships: [ - { - foreignKeyName: "inventory_reservations_cart_fk" - columns: ["cart_id"] - isOneToOne: false - referencedRelation: "carts" - referencedColumns: ["id"] - }, - { - foreignKeyName: "inventory_reservations_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "active_orders_by_user" - referencedColumns: ["id"] - }, - { - foreignKeyName: "inventory_reservations_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "orders" - referencedColumns: ["id"] - }, - { - foreignKeyName: "inventory_reservations_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "in_stock_skus" - referencedColumns: ["id"] - }, - { - foreignKeyName: "inventory_reservations_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "skus" - referencedColumns: ["id"] - }, - ] - } - inventory_transactions: { - Row: { - created_at: string | null - id: string - notes: string | null - order_id: string | null - order_item_id: string | null - performed_by_user_id: string | null - quantity_after: number - quantity_before: number - quantity_change: number - reason: string | null - sku_id: string - transaction_type: string - } - Insert: { - created_at?: string | null - id?: string - notes?: string | null - order_id?: string | null - order_item_id?: string | null - performed_by_user_id?: string | null - quantity_after: number - quantity_before: number - quantity_change: number - reason?: string | null - sku_id: string - transaction_type: string - } - Update: { - created_at?: string | null - id?: string - notes?: string | null - order_id?: string | null - order_item_id?: string | null - performed_by_user_id?: string | null - quantity_after?: number - quantity_before?: number - quantity_change?: number - reason?: string | null - sku_id?: string - transaction_type?: string - } - Relationships: [ - { - foreignKeyName: "inventory_transactions_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "active_orders_by_user" - referencedColumns: ["id"] - }, - { - foreignKeyName: "inventory_transactions_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "orders" - referencedColumns: ["id"] - }, - { - foreignKeyName: "inventory_transactions_order_item_fk" - columns: ["order_item_id"] - isOneToOne: false - referencedRelation: "order_items" - referencedColumns: ["id"] - }, - { - foreignKeyName: "inventory_transactions_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "in_stock_skus" - referencedColumns: ["id"] - }, - { - foreignKeyName: "inventory_transactions_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "skus" - referencedColumns: ["id"] - }, - ] - } - likes: { - Row: { - created_at: string | null - target_id: string - target_type_code: string - user_id: string - } - Insert: { - created_at?: string | null - target_id: string - target_type_code: string - user_id: string - } - Update: { - created_at?: string | null - target_id?: string - target_type_code?: string - user_id?: string - } - Relationships: [ - { - foreignKeyName: "likes_entity_type_fk" - columns: ["target_type_code"] - isOneToOne: false - referencedRelation: "entity_types" - referencedColumns: ["code"] - }, - ] - } - moderation_log: { - Row: { - action_type: string - created_at: string | null - duration_hours: number | null - id: string - internal_notes: string | null - moderator_id: string - reason: string - target_user_id: string - } - Insert: { - action_type: string - created_at?: string | null - duration_hours?: number | null - id?: string - internal_notes?: string | null - moderator_id: string - reason: string - target_user_id: string - } - Update: { - action_type?: string - created_at?: string | null - duration_hours?: number | null - id?: string - internal_notes?: string | null - moderator_id?: string - reason?: string - target_user_id?: string - } - Relationships: [] - } - moderation_statuses: { - Row: { - code: string - created_at: string | null - description: string | null - display_name: string - is_visible: boolean - requires_review: boolean - sort_order: number - } - Insert: { - code: string - created_at?: string | null - description?: string | null - display_name: string - is_visible?: boolean - requires_review?: boolean - sort_order?: number - } - Update: { - code?: string - created_at?: string | null - description?: string | null - display_name?: string - is_visible?: boolean - requires_review?: boolean - sort_order?: number - } - Relationships: [] - } - neptune_sync_cursors: { - Row: { - consecutive_errors: number | null - created_at: string | null - direction: string - entity_type: string - id: string - is_active: boolean | null - last_commit_num: number | null - last_error: string | null - last_synced_at: string | null - last_synced_uri: string | null - sync_type: string - updated_at: string | null - } - Insert: { - consecutive_errors?: number | null - created_at?: string | null - direction: string - entity_type: string - id: string - is_active?: boolean | null - last_commit_num?: number | null - last_error?: string | null - last_synced_at?: string | null - last_synced_uri?: string | null - sync_type: string - updated_at?: string | null - } - Update: { - consecutive_errors?: number | null - created_at?: string | null - direction?: string - entity_type?: string - id?: string - is_active?: boolean | null - last_commit_num?: number | null - last_error?: string | null - last_synced_at?: string | null - last_synced_uri?: string | null - sync_type?: string - updated_at?: string | null - } - Relationships: [] - } - neptune_sync_events: { - Row: { - created_at: string | null - direction: string - entity_type: string - entity_uri: string - error_code: string | null - error_message: string | null - id: string - idempotency_key: string | null - max_retries: number | null - neptune_commit_num: number | null - next_retry_at: string | null - operation: string - payload: Json | null - postgres_id: string | null - postgres_table: string | null - retry_count: number | null - started_at: string | null - sync_status: string | null - synced_at: string | null - } - Insert: { - created_at?: string | null - direction: string - entity_type: string - entity_uri: string - error_code?: string | null - error_message?: string | null - id?: string - idempotency_key?: string | null - max_retries?: number | null - neptune_commit_num?: number | null - next_retry_at?: string | null - operation: string - payload?: Json | null - postgres_id?: string | null - postgres_table?: string | null - retry_count?: number | null - started_at?: string | null - sync_status?: string | null - synced_at?: string | null - } - Update: { - created_at?: string | null - direction?: string - entity_type?: string - entity_uri?: string - error_code?: string | null - error_message?: string | null - id?: string - idempotency_key?: string | null - max_retries?: number | null - neptune_commit_num?: number | null - next_retry_at?: string | null - operation?: string - payload?: Json | null - postgres_id?: string | null - postgres_table?: string | null - retry_count?: number | null - started_at?: string | null - sync_status?: string | null - synced_at?: string | null - } - Relationships: [] - } - notifications: { - Row: { - action_url: string | null - created_at: string | null - data: Json - expires_at: string | null - id: string - read_at: string | null - type: string - user_id: string - } - Insert: { - action_url?: string | null - created_at?: string | null - data: Json - expires_at?: string | null - id?: string - read_at?: string | null - type: string - user_id: string - } - Update: { - action_url?: string | null - created_at?: string | null - data?: Json - expires_at?: string | null - id?: string - read_at?: string | null - type?: string - user_id?: string - } - Relationships: [] - } - order_items: { - Row: { - attributes_snapshot: Json | null - created_at: string | null - fulfilled_at: string | null - id: string - order_id: string - product_snapshot: Json | null - product_title: string - quantity: number - sku_code: string - sku_id: string - subtotal_cents: number - unit_price_cents: number - } - Insert: { - attributes_snapshot?: Json | null - created_at?: string | null - fulfilled_at?: string | null - id?: string - order_id: string - product_snapshot?: Json | null - product_title: string - quantity: number - sku_code: string - sku_id: string - subtotal_cents: number - unit_price_cents: number - } - Update: { - attributes_snapshot?: Json | null - created_at?: string | null - fulfilled_at?: string | null - id?: string - order_id?: string - product_snapshot?: Json | null - product_title?: string - quantity?: number - sku_code?: string - sku_id?: string - subtotal_cents?: number - unit_price_cents?: number - } - Relationships: [ - { - foreignKeyName: "order_items_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "active_orders_by_user" - referencedColumns: ["id"] - }, - { - foreignKeyName: "order_items_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "orders" - referencedColumns: ["id"] - }, - { - foreignKeyName: "order_items_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "in_stock_skus" - referencedColumns: ["id"] - }, - { - foreignKeyName: "order_items_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "skus" - referencedColumns: ["id"] - }, - ] - } - order_status_history: { - Row: { - changed_by_system: string | null - changed_by_user_id: string | null - created_at: string | null - from_status_code: string | null - id: string - metadata: Json | null - notes: string | null - order_id: string - to_status_code: string - } - Insert: { - changed_by_system?: string | null - changed_by_user_id?: string | null - created_at?: string | null - from_status_code?: string | null - id?: string - metadata?: Json | null - notes?: string | null - order_id: string - to_status_code: string - } - Update: { - changed_by_system?: string | null - changed_by_user_id?: string | null - created_at?: string | null - from_status_code?: string | null - id?: string - metadata?: Json | null - notes?: string | null - order_id?: string - to_status_code?: string - } - Relationships: [ - { - foreignKeyName: "order_status_history_from_fk" - columns: ["from_status_code"] - isOneToOne: false - referencedRelation: "order_statuses" - referencedColumns: ["code"] - }, - { - foreignKeyName: "order_status_history_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "active_orders_by_user" - referencedColumns: ["id"] - }, - { - foreignKeyName: "order_status_history_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "orders" - referencedColumns: ["id"] - }, - { - foreignKeyName: "order_status_history_to_fk" - columns: ["to_status_code"] - isOneToOne: false - referencedRelation: "order_statuses" - referencedColumns: ["code"] - }, - ] - } - order_statuses: { - Row: { - code: string - created_at: string | null - customer_visible: string | null - description: string | null - display_name: string - is_cancellable: boolean - is_final: boolean - is_refundable: boolean - sort_order: number - } - Insert: { - code: string - created_at?: string | null - customer_visible?: string | null - description?: string | null - display_name: string - is_cancellable?: boolean - is_final?: boolean - is_refundable?: boolean - sort_order?: number - } - Update: { - code?: string - created_at?: string | null - customer_visible?: string | null - description?: string | null - display_name?: string - is_cancellable?: boolean - is_final?: boolean - is_refundable?: boolean - sort_order?: number - } - Relationships: [] - } - orders: { - Row: { - billing_address: Json | null - cancellation_reason: string | null - cancelled_at: string | null - cart_id: string | null - created_at: string | null - created_by_user_id: string | null - currency: string | null - delivered_at: string | null - discount_cents: number | null - id: string - order_number: string - paid_at: string | null - payment_method: string | null - payment_provider: string | null - payment_provider_id: string | null - payment_status: string | null - refund_amount_cents: number | null - refunded_at: string | null - shipped_at: string | null - shipping_address: Json | null - shipping_cents: number | null - shipping_method: string | null - status_code: string - subtotal_cents: number - tax_cents: number | null - total_cents: number - tracking_number: string | null - updated_at: string | null - updated_by_user_id: string | null - user_id: string - version: number | null - } - Insert: { - billing_address?: Json | null - cancellation_reason?: string | null - cancelled_at?: string | null - cart_id?: string | null - created_at?: string | null - created_by_user_id?: string | null - currency?: string | null - delivered_at?: string | null - discount_cents?: number | null - id?: string - order_number: string - paid_at?: string | null - payment_method?: string | null - payment_provider?: string | null - payment_provider_id?: string | null - payment_status?: string | null - refund_amount_cents?: number | null - refunded_at?: string | null - shipped_at?: string | null - shipping_address?: Json | null - shipping_cents?: number | null - shipping_method?: string | null - status_code?: string - subtotal_cents: number - tax_cents?: number | null - total_cents: number - tracking_number?: string | null - updated_at?: string | null - updated_by_user_id?: string | null - user_id: string - version?: number | null - } - Update: { - billing_address?: Json | null - cancellation_reason?: string | null - cancelled_at?: string | null - cart_id?: string | null - created_at?: string | null - created_by_user_id?: string | null - currency?: string | null - delivered_at?: string | null - discount_cents?: number | null - id?: string - order_number?: string - paid_at?: string | null - payment_method?: string | null - payment_provider?: string | null - payment_provider_id?: string | null - payment_status?: string | null - refund_amount_cents?: number | null - refunded_at?: string | null - shipped_at?: string | null - shipping_address?: Json | null - shipping_cents?: number | null - shipping_method?: string | null - status_code?: string - subtotal_cents?: number - tax_cents?: number | null - total_cents?: number - tracking_number?: string | null - updated_at?: string | null - updated_by_user_id?: string | null - user_id?: string - version?: number | null - } - Relationships: [ - { - foreignKeyName: "orders_cart_fk" - columns: ["cart_id"] - isOneToOne: false - referencedRelation: "carts" - referencedColumns: ["id"] - }, - { - foreignKeyName: "orders_status_fk" - columns: ["status_code"] - isOneToOne: false - referencedRelation: "order_statuses" - referencedColumns: ["code"] - }, - ] - } - ownership_types: { - Row: { - code: string - created_at: string | null - description: string | null - display_name: string - sort_order: number - } - Insert: { - code: string - created_at?: string | null - description?: string | null - display_name: string - sort_order?: number - } - Update: { - code?: string - created_at?: string | null - description?: string | null - display_name?: string - sort_order?: number - } - Relationships: [] - } - payment_transactions: { - Row: { - amount_cents: number - created_at: string | null - currency: string - failure_code: string | null - failure_message: string | null - id: string - metadata: Json | null - order_id: string - payment_provider: string - provider_transaction_id: string | null - status: string - transaction_type: string - updated_at: string | null - } - Insert: { - amount_cents: number - created_at?: string | null - currency: string - failure_code?: string | null - failure_message?: string | null - id?: string - metadata?: Json | null - order_id: string - payment_provider: string - provider_transaction_id?: string | null - status: string - transaction_type: string - updated_at?: string | null - } - Update: { - amount_cents?: number - created_at?: string | null - currency?: string - failure_code?: string | null - failure_message?: string | null - id?: string - metadata?: Json | null - order_id?: string - payment_provider?: string - provider_transaction_id?: string | null - status?: string - transaction_type?: string - updated_at?: string | null - } - Relationships: [ - { - foreignKeyName: "payment_transactions_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "active_orders_by_user" - referencedColumns: ["id"] - }, - { - foreignKeyName: "payment_transactions_order_fk" - columns: ["order_id"] - isOneToOne: false - referencedRelation: "orders" - referencedColumns: ["id"] - }, - ] - } - product_formats: { - Row: { - active_since: string | null - code: string - created_at: string | null - deprecated_at: string | null - description: string | null - display_name: string - requires_shipping: boolean - sort_order: number - } - Insert: { - active_since?: string | null - code: string - created_at?: string | null - deprecated_at?: string | null - description?: string | null - display_name: string - requires_shipping?: boolean - sort_order?: number - } - Update: { - active_since?: string | null - code?: string - created_at?: string | null - deprecated_at?: string | null - description?: string | null - display_name?: string - requires_shipping?: boolean - sort_order?: number - } - Relationships: [] - } - product_mediums: { - Row: { - active_since: string | null - category: string | null - code: string - created_at: string | null - deprecated_at: string | null - description: string | null - display_name: string - sort_order: number - } - Insert: { - active_since?: string | null - category?: string | null - code: string - created_at?: string | null - deprecated_at?: string | null - description?: string | null - display_name: string - sort_order?: number - } - Update: { - active_since?: string | null - category?: string | null - code?: string - created_at?: string | null - deprecated_at?: string | null - description?: string | null - display_name?: string - sort_order?: number - } - Relationships: [] - } - product_statuses: { - Row: { - code: string - created_at: string | null - description: string | null - display_name: string - is_available_for_purchase: boolean - sort_order: number - } - Insert: { - code: string - created_at?: string | null - description?: string | null - display_name: string - is_available_for_purchase?: boolean - sort_order?: number - } - Update: { - code?: string - created_at?: string | null - description?: string | null - display_name?: string - is_available_for_purchase?: boolean - sort_order?: number - } - Relationships: [] - } - products: { - Row: { - archived_at: string | null - base_price_cents: number - cover_image_url: string | null - created_at: string | null - created_by_user_id: string | null - currency: string | null - description: string | null - featured_until: string | null - format_code: string - id: string - images: Json | null - last_synced_from_neptune_at: string | null - medium_code: string - narrative_unit_uri: string | null - preorder_closes_at: string | null - preorder_opens_at: string | null - publisher_name: string | null - rating_avg: number | null - rating_count: number | null - release_date: string | null - review_count: number | null - sales_total: number | null - sku: string - slug: string - status_code: string - story_expression_uri: string | null - story_work_uri: string | null - title: string - updated_at: string | null - updated_by_user_id: string | null - } - Insert: { - archived_at?: string | null - base_price_cents: number - cover_image_url?: string | null - created_at?: string | null - created_by_user_id?: string | null - currency?: string | null - description?: string | null - featured_until?: string | null - format_code: string - id?: string - images?: Json | null - last_synced_from_neptune_at?: string | null - medium_code: string - narrative_unit_uri?: string | null - preorder_closes_at?: string | null - preorder_opens_at?: string | null - publisher_name?: string | null - rating_avg?: number | null - rating_count?: number | null - release_date?: string | null - review_count?: number | null - sales_total?: number | null - sku: string - slug: string - status_code?: string - story_expression_uri?: string | null - story_work_uri?: string | null - title: string - updated_at?: string | null - updated_by_user_id?: string | null - } - Update: { - archived_at?: string | null - base_price_cents?: number - cover_image_url?: string | null - created_at?: string | null - created_by_user_id?: string | null - currency?: string | null - description?: string | null - featured_until?: string | null - format_code?: string - id?: string - images?: Json | null - last_synced_from_neptune_at?: string | null - medium_code?: string - narrative_unit_uri?: string | null - preorder_closes_at?: string | null - preorder_opens_at?: string | null - publisher_name?: string | null - rating_avg?: number | null - rating_count?: number | null - release_date?: string | null - review_count?: number | null - sales_total?: number | null - sku?: string - slug?: string - status_code?: string - story_expression_uri?: string | null - story_work_uri?: string | null - title?: string - updated_at?: string | null - updated_by_user_id?: string | null - } - Relationships: [ - { - foreignKeyName: "products_format_fk" - columns: ["format_code"] - isOneToOne: false - referencedRelation: "product_formats" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_medium_fk" - columns: ["medium_code"] - isOneToOne: false - referencedRelation: "product_mediums" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_status_fk" - columns: ["status_code"] - isOneToOne: false - referencedRelation: "product_statuses" - referencedColumns: ["code"] - }, - ] - } - profile_statuses: { - Row: { - can_login: boolean - code: string - created_at: string | null - description: string | null - display_name: string - requires_moderation: boolean - sort_order: number - } - Insert: { - can_login?: boolean - code: string - created_at?: string | null - description?: string | null - display_name: string - requires_moderation?: boolean - sort_order?: number - } - Update: { - can_login?: boolean - code?: string - created_at?: string | null - description?: string | null - display_name?: string - requires_moderation?: boolean - sort_order?: number - } - Relationships: [] - } - profiles: { - Row: { - avatar_url: string | null - banner_url: string | null - bio: string | null - birth_date: string | null - collections_count: number | null - created_at: string | null - display_name: string | null - entity_type_code: string | null - followers_count: number | null - following_count: number | null - id: string - is_neptune_entity: boolean - items_owned_count: number | null - last_active_at: string | null - location: string | null - neptune_last_commit_num: number | null - neptune_metadata: Json | null - neptune_synced_at: string | null - neptune_uri: string | null - preferences: Json | null - privacy: Json | null - reviews_count: number | null - status_code: string - subscription_expires_at: string | null - subscription_tier_code: string | null - suspended_at: string | null - suspended_by_user_id: string | null - suspended_until: string | null - suspension_reason: string | null - updated_at: string | null - username: string - verified_at: string | null - verified_by_user_id: string | null - website: string | null - } - Insert: { - avatar_url?: string | null - banner_url?: string | null - bio?: string | null - birth_date?: string | null - collections_count?: number | null - created_at?: string | null - display_name?: string | null - entity_type_code?: string | null - followers_count?: number | null - following_count?: number | null - id: string - is_neptune_entity?: boolean - items_owned_count?: number | null - last_active_at?: string | null - location?: string | null - neptune_last_commit_num?: number | null - neptune_metadata?: Json | null - neptune_synced_at?: string | null - neptune_uri?: string | null - preferences?: Json | null - privacy?: Json | null - reviews_count?: number | null - status_code?: string - subscription_expires_at?: string | null - subscription_tier_code?: string | null - suspended_at?: string | null - suspended_by_user_id?: string | null - suspended_until?: string | null - suspension_reason?: string | null - updated_at?: string | null - username: string - verified_at?: string | null - verified_by_user_id?: string | null - website?: string | null - } - Update: { - avatar_url?: string | null - banner_url?: string | null - bio?: string | null - birth_date?: string | null - collections_count?: number | null - created_at?: string | null - display_name?: string | null - entity_type_code?: string | null - followers_count?: number | null - following_count?: number | null - id?: string - is_neptune_entity?: boolean - items_owned_count?: number | null - last_active_at?: string | null - location?: string | null - neptune_last_commit_num?: number | null - neptune_metadata?: Json | null - neptune_synced_at?: string | null - neptune_uri?: string | null - preferences?: Json | null - privacy?: Json | null - reviews_count?: number | null - status_code?: string - subscription_expires_at?: string | null - subscription_tier_code?: string | null - suspended_at?: string | null - suspended_by_user_id?: string | null - suspended_until?: string | null - suspension_reason?: string | null - updated_at?: string | null - username?: string - verified_at?: string | null - verified_by_user_id?: string | null - website?: string | null - } - Relationships: [ - { - foreignKeyName: "profiles_status_fk" - columns: ["status_code"] - isOneToOne: false - referencedRelation: "profile_statuses" - referencedColumns: ["code"] - }, - { - foreignKeyName: "profiles_subscription_tier_fk" - columns: ["subscription_tier_code"] - isOneToOne: false - referencedRelation: "subscription_tiers" - referencedColumns: ["code"] - }, - ] - } - pull_list_items: { - Row: { - added_by: string - created_at: string | null - follow_id: string | null - id: string - notes: string | null - product_id: string - quantity: number | null - release_week: string - removed_at: string | null - retailer_confirmed_at: string | null - retailer_id: string | null - sent_to_retailer_at: string | null - sku_id: string | null - status_code: string - updated_at: string | null - user_id: string - } - Insert: { - added_by: string - created_at?: string | null - follow_id?: string | null - id?: string - notes?: string | null - product_id: string - quantity?: number | null - release_week: string - removed_at?: string | null - retailer_confirmed_at?: string | null - retailer_id?: string | null - sent_to_retailer_at?: string | null - sku_id?: string | null - status_code?: string - updated_at?: string | null - user_id: string - } - Update: { - added_by?: string - created_at?: string | null - follow_id?: string | null - id?: string - notes?: string | null - product_id?: string - quantity?: number | null - release_week?: string - removed_at?: string | null - retailer_confirmed_at?: string | null - retailer_id?: string | null - sent_to_retailer_at?: string | null - sku_id?: string | null - status_code?: string - updated_at?: string | null - user_id?: string - } - Relationships: [ - { - foreignKeyName: "pull_list_follow_fk" - columns: ["follow_id"] - isOneToOne: false - referencedRelation: "user_follows" - referencedColumns: ["id"] - }, - { - foreignKeyName: "pull_list_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "available_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "pull_list_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "pull_list_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products_with_stats" - referencedColumns: ["id"] - }, - { - foreignKeyName: "pull_list_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_owned_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "pull_list_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_wishlist" - referencedColumns: ["id"] - }, - { - foreignKeyName: "pull_list_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "in_stock_skus" - referencedColumns: ["id"] - }, - { - foreignKeyName: "pull_list_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "skus" - referencedColumns: ["id"] - }, - { - foreignKeyName: "pull_list_status_fk" - columns: ["status_code"] - isOneToOne: false - referencedRelation: "pull_list_statuses" - referencedColumns: ["code"] - }, - ] - } - pull_list_statuses: { - Row: { - code: string - created_at: string | null - description: string | null - display_name: string - is_cancellable: boolean - is_final: boolean - sort_order: number | null - } - Insert: { - code: string - created_at?: string | null - description?: string | null - display_name: string - is_cancellable?: boolean - is_final?: boolean - sort_order?: number | null - } - Update: { - code?: string - created_at?: string | null - description?: string | null - display_name?: string - is_cancellable?: boolean - is_final?: boolean - sort_order?: number | null - } - Relationships: [] - } - review_votes: { - Row: { - created_at: string | null - is_helpful: boolean - review_id: string - user_id: string - } - Insert: { - created_at?: string | null - is_helpful: boolean - review_id: string - user_id: string - } - Update: { - created_at?: string | null - is_helpful?: boolean - review_id?: string - user_id?: string - } - Relationships: [ - { - foreignKeyName: "review_votes_review_fk" - columns: ["review_id"] - isOneToOne: false - referencedRelation: "approved_reviews" - referencedColumns: ["id"] - }, - { - foreignKeyName: "review_votes_review_fk" - columns: ["review_id"] - isOneToOne: false - referencedRelation: "reviews" - referencedColumns: ["id"] - }, - ] - } - reviews: { - Row: { - comment_count: number | null - content: string - content_html: string | null - created_at: string | null - deleted_at: string | null - edited_at: string | null - featured_until: string | null - helpful_count: number | null - id: string - moderation_status_code: string - purchase_verified_at: string | null - rating: number - spoiler_marked_at: string | null - target_id: string - target_type_code: string - title: string | null - unhelpful_count: number | null - updated_at: string | null - user_id: string - } - Insert: { - comment_count?: number | null - content: string - content_html?: string | null - created_at?: string | null - deleted_at?: string | null - edited_at?: string | null - featured_until?: string | null - helpful_count?: number | null - id?: string - moderation_status_code?: string - purchase_verified_at?: string | null - rating: number - spoiler_marked_at?: string | null - target_id: string - target_type_code: string - title?: string | null - unhelpful_count?: number | null - updated_at?: string | null - user_id: string - } - Update: { - comment_count?: number | null - content?: string - content_html?: string | null - created_at?: string | null - deleted_at?: string | null - edited_at?: string | null - featured_until?: string | null - helpful_count?: number | null - id?: string - moderation_status_code?: string - purchase_verified_at?: string | null - rating?: number - spoiler_marked_at?: string | null - target_id?: string - target_type_code?: string - title?: string | null - unhelpful_count?: number | null - updated_at?: string | null - user_id?: string - } - Relationships: [ - { - foreignKeyName: "reviews_entity_type_fk" - columns: ["target_type_code"] - isOneToOne: false - referencedRelation: "entity_types" - referencedColumns: ["code"] - }, - { - foreignKeyName: "reviews_moderation_fk" - columns: ["moderation_status_code"] - isOneToOne: false - referencedRelation: "moderation_statuses" - referencedColumns: ["code"] - }, - ] - } - sku_attributes: { - Row: { - attribute_option_id: string - sku_id: string - } - Insert: { - attribute_option_id: string - sku_id: string - } - Update: { - attribute_option_id?: string - sku_id?: string - } - Relationships: [ - { - foreignKeyName: "sku_attributes_option_fk" - columns: ["attribute_option_id"] - isOneToOne: false - referencedRelation: "attribute_options" - referencedColumns: ["id"] - }, - { - foreignKeyName: "sku_attributes_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "in_stock_skus" - referencedColumns: ["id"] - }, - { - foreignKeyName: "sku_attributes_sku_fk" - columns: ["sku_id"] - isOneToOne: false - referencedRelation: "skus" - referencedColumns: ["id"] - }, - ] - } - skus: { - Row: { - available_from: string | null - available_until: string | null - backorder_allowed_until: string | null - created_at: string | null - created_by_user_id: string | null - id: string - low_stock_threshold: number | null - original_price_cents: number | null - price_cents: number - product_id: string - sku_code: string - stock_quantity: number | null - updated_at: string | null - updated_by_user_id: string | null - } - Insert: { - available_from?: string | null - available_until?: string | null - backorder_allowed_until?: string | null - created_at?: string | null - created_by_user_id?: string | null - id?: string - low_stock_threshold?: number | null - original_price_cents?: number | null - price_cents: number - product_id: string - sku_code: string - stock_quantity?: number | null - updated_at?: string | null - updated_by_user_id?: string | null - } - Update: { - available_from?: string | null - available_until?: string | null - backorder_allowed_until?: string | null - created_at?: string | null - created_by_user_id?: string | null - id?: string - low_stock_threshold?: number | null - original_price_cents?: number | null - price_cents?: number - product_id?: string - sku_code?: string - stock_quantity?: number | null - updated_at?: string | null - updated_by_user_id?: string | null - } - Relationships: [ - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "available_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products_with_stats" - referencedColumns: ["id"] - }, - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_owned_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_wishlist" - referencedColumns: ["id"] - }, - ] - } - stripe_customers: { - Row: { - billing_address: Json | null - created_at: string | null - id: string - payment_method: Json | null - stripe_customer_id: string - updated_at: string | null - user_id: string - } - Insert: { - billing_address?: Json | null - created_at?: string | null - id?: string - payment_method?: Json | null - stripe_customer_id: string - updated_at?: string | null - user_id: string - } - Update: { - billing_address?: Json | null - created_at?: string | null - id?: string - payment_method?: Json | null - stripe_customer_id?: string - updated_at?: string | null - user_id?: string - } - Relationships: [] - } - stripe_prices: { - Row: { - active: boolean | null - billing_scheme: string | null - created_at: string | null - currency: string - id: string - metadata: Json | null - recurring_interval: string | null - recurring_interval_count: number | null - stripe_price_id: string - stripe_product_id: string - type: string - unit_amount: number | null - updated_at: string | null - } - Insert: { - active?: boolean | null - billing_scheme?: string | null - created_at?: string | null - currency: string - id?: string - metadata?: Json | null - recurring_interval?: string | null - recurring_interval_count?: number | null - stripe_price_id: string - stripe_product_id: string - type: string - unit_amount?: number | null - updated_at?: string | null - } - Update: { - active?: boolean | null - billing_scheme?: string | null - created_at?: string | null - currency?: string - id?: string - metadata?: Json | null - recurring_interval?: string | null - recurring_interval_count?: number | null - stripe_price_id?: string - stripe_product_id?: string - type?: string - unit_amount?: number | null - updated_at?: string | null - } - Relationships: [ - { - foreignKeyName: "stripe_prices_product_fk" - columns: ["stripe_product_id"] - isOneToOne: false - referencedRelation: "stripe_products" - referencedColumns: ["id"] - }, - ] - } - stripe_products: { - Row: { - active: boolean | null - created_at: string | null - description: string | null - id: string - images: string[] | null - metadata: Json | null - name: string - stripe_product_id: string - updated_at: string | null - } - Insert: { - active?: boolean | null - created_at?: string | null - description?: string | null - id?: string - images?: string[] | null - metadata?: Json | null - name: string - stripe_product_id: string - updated_at?: string | null - } - Update: { - active?: boolean | null - created_at?: string | null - description?: string | null - id?: string - images?: string[] | null - metadata?: Json | null - name?: string - stripe_product_id?: string - updated_at?: string | null - } - Relationships: [] - } - stripe_subscriptions: { - Row: { - cancel_at_period_end: boolean | null - canceled_at: string | null - cancellation_reason: string | null - created_at: string | null - current_period_end: string - current_period_start: string - id: string - metadata: Json | null - status: string - stripe_customer_id: string - stripe_price_id: string - stripe_product_id: string - stripe_subscription_id: string - trial_end: string | null - trial_start: string | null - updated_at: string | null - user_id: string - } - Insert: { - cancel_at_period_end?: boolean | null - canceled_at?: string | null - cancellation_reason?: string | null - created_at?: string | null - current_period_end: string - current_period_start: string - id?: string - metadata?: Json | null - status: string - stripe_customer_id: string - stripe_price_id: string - stripe_product_id: string - stripe_subscription_id: string - trial_end?: string | null - trial_start?: string | null - updated_at?: string | null - user_id: string - } - Update: { - cancel_at_period_end?: boolean | null - canceled_at?: string | null - cancellation_reason?: string | null - created_at?: string | null - current_period_end?: string - current_period_start?: string - id?: string - metadata?: Json | null - status?: string - stripe_customer_id?: string - stripe_price_id?: string - stripe_product_id?: string - stripe_subscription_id?: string - trial_end?: string | null - trial_start?: string | null - updated_at?: string | null - user_id?: string - } - Relationships: [ - { - foreignKeyName: "stripe_subs_customer_fk" - columns: ["stripe_customer_id"] - isOneToOne: false - referencedRelation: "stripe_customers" - referencedColumns: ["id"] - }, - { - foreignKeyName: "stripe_subs_price_fk" - columns: ["stripe_price_id"] - isOneToOne: false - referencedRelation: "stripe_prices" - referencedColumns: ["id"] - }, - { - foreignKeyName: "stripe_subs_product_fk" - columns: ["stripe_product_id"] - isOneToOne: false - referencedRelation: "stripe_products" - referencedColumns: ["id"] - }, - ] - } - subscription_tiers: { - Row: { - active_since: string | null - code: string - created_at: string | null - deprecated_at: string | null - description: string | null - display_name: string - features: Json | null - price_cents: number | null - sort_order: number - } - Insert: { - active_since?: string | null - code: string - created_at?: string | null - deprecated_at?: string | null - description?: string | null - display_name: string - features?: Json | null - price_cents?: number | null - sort_order?: number - } - Update: { - active_since?: string | null - code?: string - created_at?: string | null - deprecated_at?: string | null - description?: string | null - display_name?: string - features?: Json | null - price_cents?: number | null - sort_order?: number - } - Relationships: [] - } - user_character_tracking: { - Row: { - preferences: Json | null - updated_at: string | null - user_id: string - } - Insert: { - preferences?: Json | null - updated_at?: string | null - user_id: string - } - Update: { - preferences?: Json | null - updated_at?: string | null - user_id?: string - } - Relationships: [] - } - user_follows: { - Row: { - created_at: string | null - id: string - muted_at: string | null - preferences: Json | null - target_id: string - target_type_code: string - unfollowed_at: string | null - user_id: string - } - Insert: { - created_at?: string | null - id?: string - muted_at?: string | null - preferences?: Json | null - target_id: string - target_type_code: string - unfollowed_at?: string | null - user_id: string - } - Update: { - created_at?: string | null - id?: string - muted_at?: string | null - preferences?: Json | null - target_id?: string - target_type_code?: string - unfollowed_at?: string | null - user_id?: string - } - Relationships: [ - { - foreignKeyName: "user_follows_entity_type_fk" - columns: ["target_type_code"] - isOneToOne: false - referencedRelation: "entity_types" - referencedColumns: ["code"] - }, - ] - } - user_product_state: { - Row: { - completed_at: string | null - consumption_status_code: string | null - created_at: string | null - id: string - last_interaction_at: string | null - owned_sku_ids: string[] | null - ownership_type_code: string | null - product_id: string - progress_percentage: number | null - purchased_at: string | null - rated_at: string | null - started_at: string | null - times_consumed: number | null - updated_at: string | null - user_id: string - user_rating: number | null - version: number | null - wishlist_notes: string | null - wishlist_priority: number | null - wishlisted_at: string | null - } - Insert: { - completed_at?: string | null - consumption_status_code?: string | null - created_at?: string | null - id?: string - last_interaction_at?: string | null - owned_sku_ids?: string[] | null - ownership_type_code?: string | null - product_id: string - progress_percentage?: number | null - purchased_at?: string | null - rated_at?: string | null - started_at?: string | null - times_consumed?: number | null - updated_at?: string | null - user_id: string - user_rating?: number | null - version?: number | null - wishlist_notes?: string | null - wishlist_priority?: number | null - wishlisted_at?: string | null - } - Update: { - completed_at?: string | null - consumption_status_code?: string | null - created_at?: string | null - id?: string - last_interaction_at?: string | null - owned_sku_ids?: string[] | null - ownership_type_code?: string | null - product_id?: string - progress_percentage?: number | null - purchased_at?: string | null - rated_at?: string | null - started_at?: string | null - times_consumed?: number | null - updated_at?: string | null - user_id?: string - user_rating?: number | null - version?: number | null - wishlist_notes?: string | null - wishlist_priority?: number | null - wishlisted_at?: string | null - } - Relationships: [ - { - foreignKeyName: "user_product_state_consumption_fk" - columns: ["consumption_status_code"] - isOneToOne: false - referencedRelation: "consumption_statuses" - referencedColumns: ["code"] - }, - { - foreignKeyName: "user_product_state_ownership_fk" - columns: ["ownership_type_code"] - isOneToOne: false - referencedRelation: "ownership_types" - referencedColumns: ["code"] - }, - { - foreignKeyName: "user_product_state_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "available_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "user_product_state_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "user_product_state_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products_with_stats" - referencedColumns: ["id"] - }, - { - foreignKeyName: "user_product_state_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_owned_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "user_product_state_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_wishlist" - referencedColumns: ["id"] - }, - ] - } - } - Views: { - active_collections: { - Row: { - collaboration_enabled_at: string | null - collection_type: string | null - cover_image_url: string | null - created_at: string | null - deleted_at: string | null - deleted_by_user_id: string | null - description: string | null - follower_count: number | null - id: string | null - item_count: number | null - like_count: number | null - name: string | null - owner_id: string | null - slug: string | null - sort_order: string | null - updated_at: string | null - visibility_code: string | null - } - Insert: { - collaboration_enabled_at?: string | null - collection_type?: string | null - cover_image_url?: string | null - created_at?: string | null - deleted_at?: string | null - deleted_by_user_id?: string | null - description?: string | null - follower_count?: number | null - id?: string | null - item_count?: number | null - like_count?: number | null - name?: string | null - owner_id?: string | null - slug?: string | null - sort_order?: string | null - updated_at?: string | null - visibility_code?: string | null - } - Update: { - collaboration_enabled_at?: string | null - collection_type?: string | null - cover_image_url?: string | null - created_at?: string | null - deleted_at?: string | null - deleted_by_user_id?: string | null - description?: string | null - follower_count?: number | null - id?: string | null - item_count?: number | null - like_count?: number | null - name?: string | null - owner_id?: string | null - slug?: string | null - sort_order?: string | null - updated_at?: string | null - visibility_code?: string | null - } - Relationships: [ - { - foreignKeyName: "collections_visibility_fk" - columns: ["visibility_code"] - isOneToOne: false - referencedRelation: "collection_visibilities" - referencedColumns: ["code"] - }, - ] - } - active_comments_with_replies: { - Row: { - actual_reply_count: number | null - content: string | null - content_html: string | null - created_at: string | null - deleted_at: string | null - depth: number | null - edited_at: string | null - id: string | null - like_count: number | null - moderated_at: string | null - moderated_by_user_id: string | null - moderation_reason: string | null - moderation_status_code: string | null - parent_comment_id: string | null - reply_count: number | null - root_comment_id: string | null - target_id: string | null - target_type_code: string | null - updated_at: string | null - user_id: string | null - } - Relationships: [ - { - foreignKeyName: "comments_entity_type_fk" - columns: ["target_type_code"] - isOneToOne: false - referencedRelation: "entity_types" - referencedColumns: ["code"] - }, - { - foreignKeyName: "comments_moderation_fk" - columns: ["moderation_status_code"] - isOneToOne: false - referencedRelation: "moderation_statuses" - referencedColumns: ["code"] - }, - { - foreignKeyName: "comments_parent_fk" - columns: ["parent_comment_id"] - isOneToOne: false - referencedRelation: "active_comments_with_replies" - referencedColumns: ["id"] - }, - { - foreignKeyName: "comments_parent_fk" - columns: ["parent_comment_id"] - isOneToOne: false - referencedRelation: "comments" - referencedColumns: ["id"] - }, - { - foreignKeyName: "comments_root_fk" - columns: ["root_comment_id"] - isOneToOne: false - referencedRelation: "active_comments_with_replies" - referencedColumns: ["id"] - }, - { - foreignKeyName: "comments_root_fk" - columns: ["root_comment_id"] - isOneToOne: false - referencedRelation: "comments" - referencedColumns: ["id"] - }, - ] - } - active_orders_by_user: { - Row: { - billing_address: Json | null - cancellation_reason: string | null - cancelled_at: string | null - cart_id: string | null - created_at: string | null - created_by_user_id: string | null - currency: string | null - delivered_at: string | null - discount_cents: number | null - id: string | null - item_count: number | null - order_number: string | null - paid_at: string | null - payment_method: string | null - payment_provider: string | null - payment_provider_id: string | null - payment_status: string | null - product_titles: string | null - refund_amount_cents: number | null - refunded_at: string | null - shipped_at: string | null - shipping_address: Json | null - shipping_cents: number | null - shipping_method: string | null - status_code: string | null - subtotal_cents: number | null - tax_cents: number | null - total_cents: number | null - tracking_number: string | null - updated_at: string | null - updated_by_user_id: string | null - user_id: string | null - version: number | null - } - Relationships: [ - { - foreignKeyName: "orders_cart_fk" - columns: ["cart_id"] - isOneToOne: false - referencedRelation: "carts" - referencedColumns: ["id"] - }, - { - foreignKeyName: "orders_status_fk" - columns: ["status_code"] - isOneToOne: false - referencedRelation: "order_statuses" - referencedColumns: ["code"] - }, - ] - } - approved_reviews: { - Row: { - comment_count: number | null - computed_helpful_count: number | null - computed_unhelpful_count: number | null - content: string | null - content_html: string | null - created_at: string | null - deleted_at: string | null - edited_at: string | null - featured_until: string | null - helpful_count: number | null - id: string | null - moderation_status_code: string | null - purchase_verified_at: string | null - rating: number | null - spoiler_marked_at: string | null - target_id: string | null - target_type_code: string | null - title: string | null - unhelpful_count: number | null - updated_at: string | null - user_id: string | null - } - Relationships: [ - { - foreignKeyName: "reviews_entity_type_fk" - columns: ["target_type_code"] - isOneToOne: false - referencedRelation: "entity_types" - referencedColumns: ["code"] - }, - { - foreignKeyName: "reviews_moderation_fk" - columns: ["moderation_status_code"] - isOneToOne: false - referencedRelation: "moderation_statuses" - referencedColumns: ["code"] - }, - ] - } - available_products: { - Row: { - archived_at: string | null - base_price_cents: number | null - cover_image_url: string | null - created_at: string | null - created_by_user_id: string | null - currency: string | null - description: string | null - featured_until: string | null - format_code: string | null - id: string | null - images: Json | null - last_synced_from_neptune_at: string | null - medium_code: string | null - narrative_unit_uri: string | null - preorder_closes_at: string | null - preorder_opens_at: string | null - publisher_name: string | null - rating_avg: number | null - rating_count: number | null - release_date: string | null - review_count: number | null - sales_total: number | null - sku: string | null - slug: string | null - status_code: string | null - story_expression_uri: string | null - story_work_uri: string | null - title: string | null - updated_at: string | null - updated_by_user_id: string | null - } - Relationships: [ - { - foreignKeyName: "products_format_fk" - columns: ["format_code"] - isOneToOne: false - referencedRelation: "product_formats" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_medium_fk" - columns: ["medium_code"] - isOneToOne: false - referencedRelation: "product_mediums" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_status_fk" - columns: ["status_code"] - isOneToOne: false - referencedRelation: "product_statuses" - referencedColumns: ["code"] - }, - ] - } - collections_with_counts: { - Row: { - actual_item_count: number | null - collaboration_enabled_at: string | null - collection_type: string | null - cover_image_url: string | null - created_at: string | null - deleted_at: string | null - deleted_by_user_id: string | null - description: string | null - follower_count: number | null - id: string | null - item_count: number | null - like_count: number | null - name: string | null - owner_id: string | null - slug: string | null - sort_order: string | null - updated_at: string | null - visibility_code: string | null - } - Relationships: [ - { - foreignKeyName: "collections_visibility_fk" - columns: ["visibility_code"] - isOneToOne: false - referencedRelation: "collection_visibilities" - referencedColumns: ["code"] - }, - ] - } - in_stock_skus: { - Row: { - available_from: string | null - available_until: string | null - backorder_allowed_until: string | null - created_at: string | null - created_by_user_id: string | null - id: string | null - low_stock_threshold: number | null - original_price_cents: number | null - price_cents: number | null - product_id: string | null - product_slug: string | null - product_title: string | null - sku_code: string | null - stock_quantity: number | null - updated_at: string | null - updated_by_user_id: string | null - } - Relationships: [ - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "available_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "products_with_stats" - referencedColumns: ["id"] - }, - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_owned_products" - referencedColumns: ["id"] - }, - { - foreignKeyName: "skus_product_fk" - columns: ["product_id"] - isOneToOne: false - referencedRelation: "user_wishlist" - referencedColumns: ["id"] - }, - ] - } - neptune_sync_cursor_status: { - Row: { - consecutive_errors: number | null - direction: string | null - entity_type: string | null - id: string | null - is_active: boolean | null - last_commit_num: number | null - last_error: string | null - last_synced_at: string | null - sync_type: string | null - } - Insert: { - consecutive_errors?: number | null - direction?: string | null - entity_type?: string | null - id?: string | null - is_active?: boolean | null - last_commit_num?: number | null - last_error?: string | null - last_synced_at?: string | null - sync_type?: string | null - } - Update: { - consecutive_errors?: number | null - direction?: string | null - entity_type?: string | null - id?: string | null - is_active?: boolean | null - last_commit_num?: number | null - last_error?: string | null - last_synced_at?: string | null - sync_type?: string | null - } - Relationships: [] - } - neptune_sync_pending_summary: { - Row: { - direction: string | null - entity_type: string | null - newest_pending: string | null - oldest_pending: string | null - pending_count: number | null - } - Relationships: [] - } - neptune_sync_retryable: { - Row: { - created_at: string | null - direction: string | null - entity_type: string | null - entity_uri: string | null - error_code: string | null - error_message: string | null - id: string | null - idempotency_key: string | null - max_retries: number | null - neptune_commit_num: number | null - next_retry_at: string | null - operation: string | null - payload: Json | null - postgres_id: string | null - postgres_table: string | null - retry_count: number | null - started_at: string | null - sync_status: string | null - synced_at: string | null - } - Insert: { - created_at?: string | null - direction?: string | null - entity_type?: string | null - entity_uri?: string | null - error_code?: string | null - error_message?: string | null - id?: string | null - idempotency_key?: string | null - max_retries?: number | null - neptune_commit_num?: number | null - next_retry_at?: string | null - operation?: string | null - payload?: Json | null - postgres_id?: string | null - postgres_table?: string | null - retry_count?: number | null - started_at?: string | null - sync_status?: string | null - synced_at?: string | null - } - Update: { - created_at?: string | null - direction?: string | null - entity_type?: string | null - entity_uri?: string | null - error_code?: string | null - error_message?: string | null - id?: string | null - idempotency_key?: string | null - max_retries?: number | null - neptune_commit_num?: number | null - next_retry_at?: string | null - operation?: string | null - payload?: Json | null - postgres_id?: string | null - postgres_table?: string | null - retry_count?: number | null - started_at?: string | null - sync_status?: string | null - synced_at?: string | null - } - Relationships: [] - } - products_with_stats: { - Row: { - archived_at: string | null - base_price_cents: number | null - computed_avg_rating: number | null - computed_review_count: number | null - cover_image_url: string | null - created_at: string | null - created_by_user_id: string | null - currency: string | null - description: string | null - featured_until: string | null - format_code: string | null - id: string | null - images: Json | null - last_synced_from_neptune_at: string | null - medium_code: string | null - narrative_unit_uri: string | null - owners_count: number | null - preorder_closes_at: string | null - preorder_opens_at: string | null - publisher_name: string | null - rating_avg: number | null - rating_count: number | null - release_date: string | null - review_count: number | null - sales_total: number | null - sku: string | null - slug: string | null - status_code: string | null - story_expression_uri: string | null - story_work_uri: string | null - title: string | null - updated_at: string | null - updated_by_user_id: string | null - wishlist_count: number | null - } - Relationships: [ - { - foreignKeyName: "products_format_fk" - columns: ["format_code"] - isOneToOne: false - referencedRelation: "product_formats" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_medium_fk" - columns: ["medium_code"] - isOneToOne: false - referencedRelation: "product_mediums" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_status_fk" - columns: ["status_code"] - isOneToOne: false - referencedRelation: "product_statuses" - referencedColumns: ["code"] - }, - ] - } - user_collections: { - Row: { - collaboration_enabled_at: string | null - collection_type: string | null - cover_image_url: string | null - created_at: string | null - deleted_at: string | null - deleted_by_user_id: string | null - description: string | null - follower_count: number | null - id: string | null - item_count: number | null - like_count: number | null - name: string | null - owner_avatar_url: string | null - owner_display_name: string | null - owner_id: string | null - owner_username: string | null - slug: string | null - sort_order: string | null - updated_at: string | null - visibility_code: string | null - } - Relationships: [ - { - foreignKeyName: "collections_visibility_fk" - columns: ["visibility_code"] - isOneToOne: false - referencedRelation: "collection_visibilities" - referencedColumns: ["code"] - }, - ] - } - user_owned_products: { - Row: { - archived_at: string | null - base_price_cents: number | null - cover_image_url: string | null - created_at: string | null - created_by_user_id: string | null - currency: string | null - description: string | null - featured_until: string | null - format_code: string | null - id: string | null - images: Json | null - last_synced_from_neptune_at: string | null - medium_code: string | null - narrative_unit_uri: string | null - ownership_type_code: string | null - preorder_closes_at: string | null - preorder_opens_at: string | null - publisher_name: string | null - purchased_at: string | null - rating_avg: number | null - rating_count: number | null - release_date: string | null - review_count: number | null - sales_total: number | null - sku: string | null - slug: string | null - status_code: string | null - story_expression_uri: string | null - story_work_uri: string | null - title: string | null - updated_at: string | null - updated_by_user_id: string | null - user_id: string | null - } - Relationships: [ - { - foreignKeyName: "products_format_fk" - columns: ["format_code"] - isOneToOne: false - referencedRelation: "product_formats" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_medium_fk" - columns: ["medium_code"] - isOneToOne: false - referencedRelation: "product_mediums" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_status_fk" - columns: ["status_code"] - isOneToOne: false - referencedRelation: "product_statuses" - referencedColumns: ["code"] - }, - { - foreignKeyName: "user_product_state_ownership_fk" - columns: ["ownership_type_code"] - isOneToOne: false - referencedRelation: "ownership_types" - referencedColumns: ["code"] - }, - ] - } - user_wishlist: { - Row: { - archived_at: string | null - base_price_cents: number | null - cover_image_url: string | null - created_at: string | null - created_by_user_id: string | null - currency: string | null - description: string | null - featured_until: string | null - format_code: string | null - id: string | null - images: Json | null - last_synced_from_neptune_at: string | null - medium_code: string | null - narrative_unit_uri: string | null - preorder_closes_at: string | null - preorder_opens_at: string | null - publisher_name: string | null - rating_avg: number | null - rating_count: number | null - release_date: string | null - review_count: number | null - sales_total: number | null - sku: string | null - slug: string | null - status_code: string | null - story_expression_uri: string | null - story_work_uri: string | null - title: string | null - updated_at: string | null - updated_by_user_id: string | null - user_id: string | null - wishlist_notes: string | null - wishlist_priority: number | null - wishlisted_at: string | null - } - Relationships: [ - { - foreignKeyName: "products_format_fk" - columns: ["format_code"] - isOneToOne: false - referencedRelation: "product_formats" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_medium_fk" - columns: ["medium_code"] - isOneToOne: false - referencedRelation: "product_mediums" - referencedColumns: ["code"] - }, - { - foreignKeyName: "products_status_fk" - columns: ["status_code"] - isOneToOne: false - referencedRelation: "product_statuses" - referencedColumns: ["code"] - }, - ] - } - } - Functions: { - claim_neptune_sync_batch: { - Args: { p_batch_size?: number; p_direction?: string } - Returns: { - created_at: string | null - direction: string - entity_type: string - entity_uri: string - error_code: string | null - error_message: string | null - id: string - idempotency_key: string | null - max_retries: number | null - neptune_commit_num: number | null - next_retry_at: string | null - operation: string - payload: Json | null - postgres_id: string | null - postgres_table: string | null - retry_count: number | null - started_at: string | null - sync_status: string | null - synced_at: string | null - }[] - SetofOptions: { - from: "*" - to: "neptune_sync_events" - isOneToOne: false - isSetofReturn: true - } - } - cleanup_old_deleted_records: { Args: never; Returns: undefined } - complete_neptune_sync: { - Args: { - p_error_code?: string - p_error_message?: string - p_event_id: string - p_success: boolean - } - Returns: boolean - } - enqueue_neptune_sync: { - Args: { - p_direction: string - p_entity_type: string - p_entity_uri: string - p_neptune_commit_num?: number - p_operation: string - p_payload?: Json - p_postgres_id?: string - p_postgres_table?: string - } - Returns: string - } - get_character_appearance_count: { - Args: { p_character_uri: string; p_user_id: string } - Returns: Json - } - get_neptune_sync_stats: { - Args: never - Returns: { - count: number - direction: string - newest_synced: string - oldest_pending: string - status: string - }[] - } - get_pull_list_week_spend: { - Args: { p_release_week: string; p_user_id: string } - Returns: Json - } - mark_abandoned_carts: { Args: never; Returns: undefined } - merge_guest_cart: { - Args: { p_session_id: string; p_user_id: string } - Returns: Json - } - release_expired_cart_reservations: { Args: never; Returns: undefined } - reset_failed_neptune_syncs: { Args: never; Returns: number } - restore_collection: { Args: { collection_id: string }; Returns: boolean } - rls_can_view_activity: { - Args: { target_user_id: string } - Returns: boolean - } - rls_can_view_collection: { - Args: { collection_id: string } - Returns: boolean - } - rls_can_view_profile: { Args: { profile_id: string }; Returns: boolean } - rls_is_following: { Args: { target_user_id: string }; Returns: boolean } - update_neptune_sync_cursor: { - Args: { - p_cursor_id: string - p_error?: string - p_last_commit_num?: number - p_last_synced_uri?: string - } - Returns: undefined - } - } - Enums: { - [_ in never]: never - } - CompositeTypes: { - [_ in never]: never - } - } -} - -type DatabaseWithoutInternals = Omit - -type DefaultSchema = DatabaseWithoutInternals[Extract] - -export type Tables< - DefaultSchemaTableNameOrOptions extends - | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & - DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & - DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { - Row: infer R - } - ? R - : never - : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & - DefaultSchema["Views"]) - ? (DefaultSchema["Tables"] & - DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { - Row: infer R - } - ? R - : never - : never - -export type TablesInsert< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Insert: infer I - } - ? I - : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] - ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Insert: infer I - } - ? I - : never - : never - -export type TablesUpdate< - DefaultSchemaTableNameOrOptions extends - | keyof DefaultSchema["Tables"] - | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, -> = DefaultSchemaTableNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { - Update: infer U - } - ? U - : never - : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] - ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { - Update: infer U - } - ? U - : never - : never - -export type Enums< - DefaultSchemaEnumNameOrOptions extends - | keyof DefaultSchema["Enums"] - | { schema: keyof DatabaseWithoutInternals }, - EnumName extends DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] - : never = never, -> = DefaultSchemaEnumNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] - : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] - ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] - : never - -export type CompositeTypes< - PublicCompositeTypeNameOrOptions extends - | keyof DefaultSchema["CompositeTypes"] - | { schema: keyof DatabaseWithoutInternals }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals - } - ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] - : never = never, -> = PublicCompositeTypeNameOrOptions extends { - schema: keyof DatabaseWithoutInternals -} - ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] - : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] - ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] - : never - -export const Constants = { - graphql_public: { - Enums: {}, - }, - public: { - Enums: {}, - }, -} as const - diff --git a/edge/_shared/utils/clients.ts b/edge/_shared/utils/clients.ts deleted file mode 100644 index a4c4c55..0000000 --- a/edge/_shared/utils/clients.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Database } from '../types/database.ts' - -import { createClient } from '@supabase/supabase-js' -import { getSupabaseConfig } from './config.ts' -import { getEnv, requireEnv } from './env.ts' - -export function createAdminClient() { - const config = getSupabaseConfig({ - publicKey: getEnv('SUPABASE_PUBLIC_KEY') || requireEnv('SUPABASE_ANON_KEY'), - secretKey: getEnv('SUPABASE_SECRET_KEY') || requireEnv('SUPABASE_SERVICE_ROLE_KEY') - }); - - return createClient( - config.url, - config.secretKey!, - ) -} - -export function createUserClient(authHeader: string) { - const config = getSupabaseConfig({ - publicKey: getEnv('SUPABASE_PUBLIC_KEY') || requireEnv('SUPABASE_ANON_KEY'), - secretKey: getEnv('SUPABASE_SECRET_KEY') || requireEnv('SUPABASE_SERVICE_ROLE_KEY') - }); - - return createClient( - config.url, - config.publicKey!, - { - global: { headers: { Authorization: authHeader } }, - auth: { persistSession: false }, - }, - ) -} diff --git a/edge/_shared/utils/config.ts b/edge/_shared/utils/config.ts deleted file mode 100644 index 92c79c7..0000000 --- a/edge/_shared/utils/config.ts +++ /dev/null @@ -1,20 +0,0 @@ -// @filename: shared/config.ts -import { getEnv, requireEnv } from './env.ts' - -/** - * Shared configuration that works across runtimes - * Falls back to environment variables automatically - */ -export interface SupabaseConfig { - url: string - publicKey?: string - secretKey?: string -} - -export function getSupabaseConfig(override?: Partial): SupabaseConfig { - return { - url: override?.url ?? requireEnv('SUPABASE_URL'), - publicKey: override?.publicKey ?? getEnv('SUPABASE_PUBLIC_KEY'), - secretKey: override?.secretKey ?? getEnv('SUPABASE_SECRET_KEY'), - } -} \ No newline at end of file diff --git a/edge/_shared/utils/env.ts b/edge/_shared/utils/env.ts deleted file mode 100644 index ab8046c..0000000 --- a/edge/_shared/utils/env.ts +++ /dev/null @@ -1,32 +0,0 @@ -/// - -/** - * Runtime detection utilities - * Determines whether code is running in Deno or Node.js - */ - -export const isDeno = 'Deno' in globalThis; -export const isNode = !isDeno - -/** - * Cross-runtime environment variable access - * Abstracts the difference between Deno.env.get() and process.env - */ -export function getEnv(key: string): string | undefined { - if (isDeno) { - return Deno.env.get(key) - } - return globalThis?.process?.env[key] -} - -/** - * Gets environment variable or throws if missing - * Useful for required configuration - */ -export function requireEnv(key: string): string { - const value = getEnv(key) - if (!value) { - throw new Error(`Missing required environment variable: ${key}`) - } - return value -} \ No newline at end of file diff --git a/edge/endpoints/bundle/mod.ts b/edge/endpoints/bundle/mod.ts deleted file mode 100644 index f24fc4e..0000000 --- a/edge/endpoints/bundle/mod.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Endpoint Definitions Registry - */ - -import BundleRun from './run/definition.ts' -import BundleFile from './file/definition.ts' -import BundleMetafile from './metafile/definition.ts' -import BundleAnalysis from './analysis/definition.ts' -import BundleWarnings from './warnings/definition.ts' -import BundleRaw from './raw/definition.ts' - -import BadgeSvg from '../badge/svg/definition.ts' -import BadgeRaster from '../badge/raster/definition.ts' - -import CachePurge from '../cache/purge/definition.ts' -import CacheClear from '../cache/clear/definition.ts' - -import OpenApi from '../static/openapi/definition.ts' -import Plugin from '../static/plugin/definition.ts' -import Robots from '../static/robots/definition.ts' -import Llm from '../static/llms/definition.ts' -import ServiceWorker from '../static/sw/definition.ts' -import Favicon from '../static/favicon/definition.ts' -import AppleTouchIcon from '../static/apple-touch-icon/definition.ts' -import AppleTouchIconPrecomposed from '../static/apple-touch-icon-precomposed/definition.ts' - -import Health from '../health/definition.ts' - -export const EndpointDefinitions = { - BundleRun, - BundleFile, - BundleMetafile, - BundleAnalysis, - BundleWarnings, - BundleRaw, - BadgeSvg, - BadgeRaster, - CachePurge, - CacheClear, - OpenApi, - Plugin, - Robots, - Llm, - ServiceWorker, - Favicon, - AppleTouchIcon, - AppleTouchIconPrecomposed, - Health, -} as const diff --git a/edge/endpoints/health/handler.ts b/edge/endpoints/health/handler.ts deleted file mode 100644 index 58209ce..0000000 --- a/edge/endpoints/health/handler.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Health Handler - * - * GET /health - */ - -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' - -import { createValidator } from '#shared/middleware/validation.ts' -import { ok } from '#shared/response/mod.ts' - -import Definition from './definition.ts' - -export type AppEnv = FunctionAppEnv - -export const Middleware: EndpointMiddlewareHandler[] = [ - createValidator('query', Definition.Schemas.Query), -] - -export const Handler: EndpointHandler = async function (c) { - return c.json(...ok({ status: 'ok' })) -} - -export default Handler diff --git a/edge/endpoints/static/apple-touch-icon-precomposed/definition.ts b/edge/endpoints/static/apple-touch-icon-precomposed/definition.ts deleted file mode 100644 index 8b554b5..0000000 --- a/edge/endpoints/static/apple-touch-icon-precomposed/definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Apple Touch Icon Precomposed Endpoint Definition - * - * GET /apple-touch-icon-precomposed.png - */ - -import type { EndpointDefinition } from '#shared/server/types.ts' -import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' - -export const QuerySchema = BaseQuerySchema - -export default { - Name: 'apple-touch-icon-precomposed', - Route: '/apple-touch-icon-precomposed.png', - Methods: ['GET'] as const, - Input: QuerySchema, - Output: z.any(), - Schemas: { - Query: QuerySchema, - }, -} as const satisfies EndpointDefinition diff --git a/edge/endpoints/static/apple-touch-icon-precomposed/handler.ts b/edge/endpoints/static/apple-touch-icon-precomposed/handler.ts deleted file mode 100644 index 92038b4..0000000 --- a/edge/endpoints/static/apple-touch-icon-precomposed/handler.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Apple Touch Icon Precomposed Handler - * - * GET /apple-touch-icon-precomposed.png - */ - -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' - -import { createValidator } from '#shared/middleware/validation.ts' - -import Definition from './definition.ts' - -export type AppEnv = FunctionAppEnv - -export const Middleware: EndpointMiddlewareHandler[] = [ - createValidator('query', Definition.Schemas.Query), -] - -export const Handler: EndpointHandler = function () { - return Response.redirect('https://bundlejs.com/favicon/apple-touch-icon.png') -} - -export default Handler diff --git a/edge/endpoints/static/apple-touch-icon/definition.ts b/edge/endpoints/static/apple-touch-icon/definition.ts deleted file mode 100644 index 2294e4e..0000000 --- a/edge/endpoints/static/apple-touch-icon/definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Apple Touch Icon Static Endpoint Definition - * - * GET /apple-touch-icon.png - */ - -import type { EndpointDefinition } from '#shared/server/types.ts' -import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' - -export const QuerySchema = BaseQuerySchema - -export default { - Name: 'apple-touch-icon', - Route: '/apple-touch-icon.png', - Methods: ['GET'] as const, - Input: QuerySchema, - Output: z.any(), - Schemas: { - Query: QuerySchema, - }, -} as const satisfies EndpointDefinition diff --git a/edge/endpoints/static/apple-touch-icon/handler.ts b/edge/endpoints/static/apple-touch-icon/handler.ts deleted file mode 100644 index ae1f398..0000000 --- a/edge/endpoints/static/apple-touch-icon/handler.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Apple Touch Icon Static Handler - * - * GET /apple-touch-icon.png - */ - -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' - -import { createValidator } from '#shared/middleware/validation.ts' - -import Definition from './definition.ts' - -export type AppEnv = FunctionAppEnv - -export const Middleware: EndpointMiddlewareHandler[] = [ - createValidator('query', Definition.Schemas.Query), -] - -export const Handler: EndpointHandler = function () { - return Response.redirect('https://bundlejs.com/favicon/apple-touch-icon.png') -} - -export default Handler diff --git a/edge/endpoints/static/favicon/definition.ts b/edge/endpoints/static/favicon/definition.ts deleted file mode 100644 index 798ed03..0000000 --- a/edge/endpoints/static/favicon/definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Favicon Static Endpoint Definition - * - * GET /favicon.ico - */ - -import type { EndpointDefinition } from '#shared/server/types.ts' -import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' - -export const QuerySchema = BaseQuerySchema - -export default { - Name: 'favicon', - Route: '/favicon.ico', - Methods: ['GET'] as const, - Input: QuerySchema, - Output: z.any(), - Schemas: { - Query: QuerySchema, - }, -} as const satisfies EndpointDefinition diff --git a/edge/endpoints/static/favicon/handler.ts b/edge/endpoints/static/favicon/handler.ts deleted file mode 100644 index ef57c3f..0000000 --- a/edge/endpoints/static/favicon/handler.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Favicon Static Handler - * - * GET /favicon.ico - */ - -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' - -import { createValidator } from '#shared/middleware/validation.ts' - -import Definition from './definition.ts' - -export type AppEnv = FunctionAppEnv - -export const Middleware: EndpointMiddlewareHandler[] = [ - createValidator('query', Definition.Schemas.Query), -] - -export const Handler: EndpointHandler = function () { - return Response.redirect('https://bundlejs.com/favicon/favicon-api.ico') -} - -export default Handler diff --git a/edge/endpoints/static/llms/definition.ts b/edge/endpoints/static/llms/definition.ts deleted file mode 100644 index abf3dbb..0000000 --- a/edge/endpoints/static/llms/definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * LLMS Static Endpoint Definition - * - * GET /llms.txt - */ - -import type { EndpointDefinition } from '#shared/server/types.ts' -import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' - -export const QuerySchema = BaseQuerySchema - -export default { - Name: 'llms', - Route: '/llms.txt', - Methods: ['GET'] as const, - Input: QuerySchema, - Output: z.any(), - Schemas: { - Query: QuerySchema, - }, -} as const satisfies EndpointDefinition diff --git a/edge/endpoints/static/llms/handler.ts b/edge/endpoints/static/llms/handler.ts deleted file mode 100644 index 245c914..0000000 --- a/edge/endpoints/static/llms/handler.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * LLMS Static Handler - * - * GET /llms.txt - */ - -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' - -import { createValidator } from '#shared/middleware/validation.ts' - -import Definition from './definition.ts' - -export type AppEnv = FunctionAppEnv - -export const Middleware: EndpointMiddlewareHandler[] = [ - createValidator('query', Definition.Schemas.Query), -] - -export const Handler: EndpointHandler = function () { - return Response.json({ message: 'no' }) -} - -export default Handler diff --git a/edge/endpoints/static/openapi/definition.ts b/edge/endpoints/static/openapi/definition.ts deleted file mode 100644 index 00be4d2..0000000 --- a/edge/endpoints/static/openapi/definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * OpenAPI Static Endpoint Definition - * - * GET /.well-known/openapi.yaml - */ - -import type { EndpointDefinition } from '#shared/server/types.ts' -import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' - -export const QuerySchema = BaseQuerySchema - -export default { - Name: 'openapi', - Route: '/.well-known/openapi.yaml', - Methods: ['GET'] as const, - Input: QuerySchema, - Output: z.any(), - Schemas: { - Query: QuerySchema, - }, -} as const satisfies EndpointDefinition diff --git a/edge/endpoints/static/openapi/handler.ts b/edge/endpoints/static/openapi/handler.ts deleted file mode 100644 index b1965a8..0000000 --- a/edge/endpoints/static/openapi/handler.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * OpenAPI Static Handler - * - * GET /.well-known/openapi.yaml - */ - -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' - -import { createValidator } from '#shared/middleware/validation.ts' - -import Definition from './definition.ts' - -export type AppEnv = FunctionAppEnv - -const OPENAPI_PATH = new URL('../../../static/.well-known/openapi.yaml', import.meta.url) - -export const Middleware: EndpointMiddlewareHandler[] = [ - createValidator('query', Definition.Schemas.Query), -] - -export const Handler: EndpointHandler = async function () { - const contents = await Deno.readFile(OPENAPI_PATH) - return new Response(contents, { - status: 200, - headers: { - 'Content-Type': 'text/yaml', - 'Cache-Control': 'max-age=180, public', - }, - }) -} - -export default Handler diff --git a/edge/endpoints/static/plugin/definition.ts b/edge/endpoints/static/plugin/definition.ts deleted file mode 100644 index a5322c8..0000000 --- a/edge/endpoints/static/plugin/definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * AI Plugin Static Endpoint Definition - * - * GET /.well-known/ai-plugin.json - */ - -import type { EndpointDefinition } from '#shared/server/types.ts' -import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' - -export const QuerySchema = BaseQuerySchema - -export default { - Name: 'plugin', - Route: '/.well-known/ai-plugin.json', - Methods: ['GET'] as const, - Input: QuerySchema, - Output: z.any(), - Schemas: { - Query: QuerySchema, - }, -} as const satisfies EndpointDefinition diff --git a/edge/endpoints/static/plugin/handler.ts b/edge/endpoints/static/plugin/handler.ts deleted file mode 100644 index f52e4ae..0000000 --- a/edge/endpoints/static/plugin/handler.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * AI Plugin Static Handler - * - * GET /.well-known/ai-plugin.json - */ - -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' - -import { createValidator } from '#shared/middleware/validation.ts' - -import Definition from './definition.ts' - -export type AppEnv = FunctionAppEnv - -const PLUGIN_PATH = new URL('../../../static/.well-known/ai-plugin.json', import.meta.url) - -export const Middleware: EndpointMiddlewareHandler[] = [ - createValidator('query', Definition.Schemas.Query), -] - -export const Handler: EndpointHandler = async function () { - const contents = await Deno.readFile(PLUGIN_PATH) - return new Response(contents, { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Cache-Control': 'max-age=180, public', - }, - }) -} - -export default Handler diff --git a/edge/endpoints/static/robots/definition.ts b/edge/endpoints/static/robots/definition.ts deleted file mode 100644 index a54ac4f..0000000 --- a/edge/endpoints/static/robots/definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Robots Static Endpoint Definition - * - * GET /robots.txt - */ - -import type { EndpointDefinition } from '#shared/server/types.ts' -import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' - -export const QuerySchema = BaseQuerySchema - -export default { - Name: 'robots', - Route: '/robots.txt', - Methods: ['GET'] as const, - Input: QuerySchema, - Output: z.any(), - Schemas: { - Query: QuerySchema, - }, -} as const satisfies EndpointDefinition diff --git a/edge/endpoints/static/robots/handler.ts b/edge/endpoints/static/robots/handler.ts deleted file mode 100644 index 5358700..0000000 --- a/edge/endpoints/static/robots/handler.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Robots Static Handler - * - * GET /robots.txt - */ - -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' - -import { createValidator } from '#shared/middleware/validation.ts' - -import Definition from './definition.ts' - -export type AppEnv = FunctionAppEnv - -export const Middleware: EndpointMiddlewareHandler[] = [ - createValidator('query', Definition.Schemas.Query), -] - -export const Handler: EndpointHandler = function () { - return Response.json({ message: 'no' }) -} - -export default Handler diff --git a/edge/endpoints/static/sw/definition.ts b/edge/endpoints/static/sw/definition.ts deleted file mode 100644 index 0d6de29..0000000 --- a/edge/endpoints/static/sw/definition.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Service Worker Static Endpoint Definition - * - * GET /sw.js - */ - -import type { EndpointDefinition } from '#shared/server/types.ts' -import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' - -export const QuerySchema = BaseQuerySchema - -export default { - Name: 'sw', - Route: '/sw.js', - Methods: ['GET'] as const, - Input: QuerySchema, - Output: z.any(), - Schemas: { - Query: QuerySchema, - }, -} as const satisfies EndpointDefinition diff --git a/edge/endpoints/static/sw/handler.ts b/edge/endpoints/static/sw/handler.ts deleted file mode 100644 index 633369a..0000000 --- a/edge/endpoints/static/sw/handler.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Service Worker Static Handler - * - * GET /sw.js - */ - -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' - -import { createValidator } from '#shared/middleware/validation.ts' - -import Definition from './definition.ts' - -export type AppEnv = FunctionAppEnv - -export const Middleware: EndpointMiddlewareHandler[] = [ - createValidator('query', Definition.Schemas.Query), -] - -export const Handler: EndpointHandler = function () { - return Response.json({ message: 'no' }) -} - -export default Handler diff --git a/edge/static/.well-known/ai-plugin.json b/edge/static/.well-known/ai-plugin.json deleted file mode 100644 index d7f4bfd..0000000 --- a/edge/static/.well-known/ai-plugin.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "schema_version": "v1", - "name_for_human": "Bundlejs API Plugin", - "name_for_model": "bundlejs_api", - "description_for_human": "Plugin for bundling, treeshaking, transpiling, compressing and minification of JavaScript and TypeScript code", - "description_for_model": "Plugin for bundling, treeshaking, transpiling, compressing and minification of JavaScript and TypeScript code.", - "auth": { - "type": "none" - }, - "api": { - "type": "openapi", - "url": "https://deno.bundlejs.com/.well-known/openapi.yaml", - "is_user_authenticated": false - }, - "logo_url": "https://deno.bundlejs.com/.well-known/logo.png", - "contact_email": "hey@okikio.dev", - "legal_info_url": "https://github.com/okikio/bundlejs/blob/deno-edge/LICENSE" -} \ No newline at end of file diff --git a/edge/static/.well-known/logo.png b/edge/static/.well-known/logo.png deleted file mode 100644 index 3f2ca4550afc96c1f64b577575d879c292d57063..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 117677 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

t13>UEwhk7FKk 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 - `q` or `query` represents the module, e.g. react, vue, etc... - - You can add (import) in-front of a specific module to make it an import instead of an export - example: - > - react,react-dom,(import)spring-easing - - name: treeshake - in: query - description: - > - Represents the export/imports to treeshake. - Treeshake export object using this custom format - - `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - required: false - schema: - type: string - example: - > - [*],[{default}] - - name: badge - in: query - description: Generate a badge. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["detailed", "minified", "uncompressed"] - - name: badge-style - in: query - description: Badge styles supported by shields.io. - required: false - schema: - type: string - enum: ["flat", "plastic", "flat-square", "for-the-badge", "social"] - - name: badge-raster - in: query - description: The badge as a png image. - required: false - schema: - type: boolean - - name: png - in: query - description: The badge as a png image. - required: false - schema: - type: boolean - - name: file - in: query - description: Resulting bundled code. - required: false - schema: - type: boolean - - name: analysis - in: query - description: Esbuild generate visual analysis. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["verbose"] - - name: metafile - in: query - description: Esbuild bundle metafile. - required: false - schema: - type: boolean - - name: config - in: query - required: false - description: Bundlejs's Config in JSON form, with support for esbuild options in the esbuild field, and "package.json" in the "package.json" field - schema: - type: string - format: json - example: - > - { - "cdn": "https://unpkg.com", - "compression": "gzip", - "analysis": true, - "polyfill": false, - "tsx": false, - "package.json": { - "name": "bundled-code", - "version": "0.0.0", - "dependencies": { - "react": "^16" - } - }, - "esbuild": { - "target": [ - "esnext", - ], - "format": "esm", - "bundle": true, - "minify": true, - "treeShaking": true, - "platform": "browser", - "sourcemap": false, - "external": ["react"], - "alias": { - "react-dom": "https://esm.sh/react-dom" - } - }, - } - - name: polyfill - in: query - description: Polyfill Node built-ins. - required: false - schema: - type: boolean - - name: sourcemap - in: query - description: Esbuild sourcemap. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["inline", "external"] - - name: minify - in: query - description: Esbuild minify. - required: false - schema: - type: boolean - - name: format - in: query - description: Esbuild format. - required: false - schema: - type: string - enum: ["iife", "cjs", "esm"] - - name: warnings - in: query - description: Lists warnings for a particular bundle. - required: false - schema: - type: boolean - - name: raw - in: query - description: The raw result of the bundle. - required: false - schema: - type: boolean - - name: text - in: query - description: Represents the input code as a string. - required: false - schema: - type: string - - name: share - in: query - description: Represents the compressed string version of the input code (compressed using @amoutonbrady/lz-string). - required: false - schema: - type: string - - name: tsx - in: query - description: Support JSX and TSX. - required: false - schema: - type: boolean - responses: - '200': - description: - > - Bundled code, analysis, badge or JSON response. - - The URL breakdown is, - - ```ts - /? - q=(import)@okikio/emitter,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate& - treeshake=[T],[{ animate }],[{ animate as B }],[* as TR],[{ type animate }],[*],[{ animate as A }],[* as PR],[{ animate }]& - text="export * as PR18 from \"@okikio/animate\";\nexport { animate as animate2 } from \"@okikio/animate\";"& - share=MYewdgziA2CmB00QHMAUAiAwiG6CUQA& - config={"cdn":"skypack","compression":"brotli","esbuild":{"format":"cjs","minify":false,"treeShaking":false}}& - bundle - ``` - - * `q` or `query` represents the module, e.g. `react`, `vue`, etc... - You can add `(import)` in-front of a specific module to make it an import instead of an export - - * `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - - The square brackets represent seperate packages, and everything inside the squarebrackets, are the exported methods, types, etc... - - * `text` represents the input code as a string (it's used for short input code) - * `share` represents compressed string version of the input code (it's used for large input code) - * `config` represents the bundle configuration to use when building the bundle - * `bundle` tells **bundlejs** to bundle the input code on start-up. This isn't on by default for security reasons. I want to discourage people from sending large complex bundles that crash browsers or that take a long time to load, especially before the input code is properly verified as non-malicious. So, if you want to bundle the code on startup, you have to manually add `&bundle` to the end of the url yourself. - - The reason why I decided on this syntax is because it allows for a lot of flexibility, and transparency concerning what is being bundled. I also wanted to make it easy to share bundle session between users. - - You can add (import) in-front of a specific module to make it an import instead of an export, treeshake represents the export/imports to treeshake. - - The treeshake syntax allows for specifying multiple exports per package, through this syntax - content: - application/json: - schema: - type: object - properties: - query: - type: string - rawQuery: - type: string - version: - type: string - nullable: true - versions: - type: array - items: - type: string - nullable: true - config: - type: object - additionalProperties: true - input: - type: string - size: - type: object - properties: - type: - type: string - rawUncompressedSize: - type: integer - uncompressedSize: - type: string - rawCompressedSize: - type: integer - compressedSize: - type: string - size: - type: string - time: - type: string - rawTime: - type: integer - fileId: - type: string - fileUrl: - type: string - fileHTMLUrl: - type: string - application/javascript: - schema: - type: string - image/svg+xml: - schema: - type: string - example: - > - - - 5.51 kB (gzip): bundlejs - - - - - - - - - - - - - - - - 5.51 kB (gzip) - - bundlejs - - - - - image/png: - schema: - type: string - format: binary - text/plain: - schema: - type: string - example: "index.js 12.9kb 100.0%\n ├ http-url:https://unpkg.com/react@18.2.0/cjs/react.production.min.js 6.3kb 49.0%\n ├ http-url:https://unpkg.com/spring-easing@2.1.2/lib/index.mjs 4.3kb 33.1%\n ├ http-url:https://unpkg.com/react@18.2.0/index.js 50b 0.4%\n â”” virtual-filesystem:/index.ts 16b 0.1%" - '400': - description: Bad request - '404': - description: Package not found - content: - text/plain: - schema: - type: string - example: "✘ [ERROR] [getRequest] Failed at request (https://unpkg.com/reac@0.0.0)\nError: Couldn't load https://unpkg.com/reac@0.0.0/index.js (404 code) [plugin cdn-url]\n\n virtual-filesystem:/index.ts:2:14:\n\n 2 │ export * from \"reac\";\n ╵ ~~~~~~" - '500': - description: Internal server error - - /badge: - get: - operationId: getBundleBadge - summary: Bundle, treeshake and minify JavaScript and TypeScript code, and respond with a badge. - parameters: - - name: q - in: query - description: Represents the modules to bundle. - required: false - schema: - type: string - description: - > - `q` or `query` represents the module, e.g. react, vue, etc... - - You can add (import) in-front of a specific module to make it an import instead of an export - example: - > - react,react-dom,(import)spring-easing - - name: treeshake - in: query - description: - > - Represents the export/imports to treeshake. - Treeshake export object using this custom format - - `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - required: false - schema: - type: string - example: - > - [*],[{default}] - - name: badge - in: query - description: Generate a badge. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["detailed", "minified", "uncompressed"] - - name: badge-style - in: query - description: Badge styles supported by shields.io. - required: false - schema: - type: string - enum: ["flat", "plastic", "flat-square", "for-the-badge", "social"] - - name: badge-raster - in: query - description: The badge as a png image. - required: false - schema: - type: boolean - - name: png - in: query - description: The badge as a png image. - required: false - schema: - type: boolean - - name: config - in: query - required: false - description: Bundlejs's Config in JSON form, with support for esbuild options in the esbuild field, and "package.json" in the "package.json" field - schema: - type: string - format: json - example: - > - { - "cdn": "https://unpkg.com", - "compression": "gzip", - "analysis": true, - "polyfill": false, - "tsx": false, - "package.json": { - "name": "bundled-code", - "version": "0.0.0", - "dependencies": { - "react": "^16" - } - }, - "esbuild": { - "target": [ - "esnext", - ], - "format": "esm", - "bundle": true, - "minify": true, - "treeShaking": true, - "platform": "browser", - "sourcemap": false, - "external": ["react"], - "alias": { - "react-dom": "https://esm.sh/react-dom" - } - }, - } - - name: polyfill - in: query - description: Polyfill Node built-ins. - required: false - schema: - type: boolean - - name: sourcemap - in: query - description: Esbuild sourcemap. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["inline", "external"] - - name: minify - in: query - description: Esbuild minify. - required: false - schema: - type: boolean - - name: format - in: query - description: Esbuild format. - required: false - schema: - type: string - enum: ["iife", "cjs", "esm"] - - name: text - in: query - description: Represents the input code as a string. - required: false - schema: - type: string - - name: share - in: query - description: Represents the compressed string version of the input code (compressed using @amoutonbrady/lz-string). - required: false - schema: - type: string - - name: tsx - in: query - description: Support JSX and TSX. - required: false - schema: - type: boolean - responses: - '200': - description: - > - Bundled code, analysis, badge or JSON response. - - The URL breakdown is, - - ```ts - /? - q=(import)@okikio/emitter,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate& - treeshake=[T],[{ animate }],[{ animate as B }],[* as TR],[{ type animate }],[*],[{ animate as A }],[* as PR],[{ animate }]& - text="export * as PR18 from \"@okikio/animate\";\nexport { animate as animate2 } from \"@okikio/animate\";"& - share=MYewdgziA2CmB00QHMAUAiAwiG6CUQA& - config={"cdn":"skypack","compression":"brotli","esbuild":{"format":"cjs","minify":false,"treeShaking":false}}& - bundle - ``` - - * `q` or `query` represents the module, e.g. `react`, `vue`, etc... - You can add `(import)` in-front of a specific module to make it an import instead of an export - - * `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - - The square brackets represent seperate packages, and everything inside the squarebrackets, are the exported methods, types, etc... - - * `text` represents the input code as a string (it's used for short input code) - * `share` represents compressed string version of the input code (it's used for large input code) - * `config` represents the bundle configuration to use when building the bundle - * `bundle` tells **bundlejs** to bundle the input code on start-up. This isn't on by default for security reasons. I want to discourage people from sending large complex bundles that crash browsers or that take a long time to load, especially before the input code is properly verified as non-malicious. So, if you want to bundle the code on startup, you have to manually add `&bundle` to the end of the url yourself. - - The reason why I decided on this syntax is because it allows for a lot of flexibility, and transparency concerning what is being bundled. I also wanted to make it easy to share bundle session between users. - - You can add (import) in-front of a specific module to make it an import instead of an export, treeshake represents the export/imports to treeshake. - - The treeshake syntax allows for specifying multiple exports per package, through this syntax - content: - image/svg+xml: - schema: - type: string - example: - > - - - 5.51 kB (gzip): bundlejs - - - - - - - - - - - - - - - - 5.51 kB (gzip) - - bundlejs - - - - image/png: - schema: - type: string - format: binary - '400': - description: Bad request - '404': - description: Package not found - content: - text/plain: - schema: - type: string - example: "✘ [ERROR] [getRequest] Failed at request (https://unpkg.com/reac@0.0.0)\nError: Couldn't load https://unpkg.com/reac@0.0.0/index.js (404 code) [plugin cdn-url]\n\n virtual-filesystem:/index.ts:2:14:\n\n 2 │ export * from \"reac\";\n ╵ ~~~~~~" - '500': - description: Internal server error - - /badge/raster: - get: - operationId: getBundleBadgeRasterPNG - summary: Bundle, treeshake and minify JavaScript and TypeScript code and respond with a badge raster badge (think png). - parameters: - - name: q - in: query - description: Represents the modules to bundle. - required: false - schema: - type: string - description: - > - `q` or `query` represents the module, e.g. react, vue, etc... - - You can add (import) in-front of a specific module to make it an import instead of an export - example: - > - react,react-dom,(import)spring-easing - - name: treeshake - in: query - description: - > - Represents the export/imports to treeshake. - Treeshake export object using this custom format - - `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - required: false - schema: - type: string - example: - > - [*],[{default}] - - name: badge - in: query - description: Generate a badge. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["detailed", "minified", "uncompressed"] - - name: badge-style - in: query - description: Badge styles supported by shields.io. - required: false - schema: - type: string - enum: ["flat", "plastic", "flat-square", "for-the-badge", "social"] - - name: badge-raster - in: query - description: The badge as a png image. - required: false - schema: - type: boolean - - name: png - in: query - description: The badge as a png image. - required: false - schema: - type: boolean - - name: config - in: query - required: false - description: Bundlejs's Config in JSON form, with support for esbuild options in the esbuild field, and "package.json" in the "package.json" field - schema: - type: string - format: json - example: - > - { - "cdn": "https://unpkg.com", - "compression": "gzip", - "analysis": true, - "polyfill": false, - "tsx": false, - "package.json": { - "name": "bundled-code", - "version": "0.0.0", - "dependencies": { - "react": "^16" - } - }, - "esbuild": { - "target": [ - "esnext", - ], - "format": "esm", - "bundle": true, - "minify": true, - "treeShaking": true, - "platform": "browser", - "sourcemap": false, - "external": ["react"], - "alias": { - "react-dom": "https://esm.sh/react-dom" - } - }, - } - - name: polyfill - in: query - description: Polyfill Node built-ins. - required: false - schema: - type: boolean - - name: sourcemap - in: query - description: Esbuild sourcemap. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["inline", "external"] - - name: minify - in: query - description: Esbuild minify. - required: false - schema: - type: boolean - - name: format - in: query - description: Esbuild format. - required: false - schema: - type: string - enum: ["iife", "cjs", "esm"] - - name: text - in: query - description: Represents the input code as a string. - required: false - schema: - type: string - - name: share - in: query - description: Represents the compressed string version of the input code (compressed using @amoutonbrady/lz-string). - required: false - schema: - type: string - - name: tsx - in: query - description: Support JSX and TSX. - required: false - schema: - type: boolean - responses: - '200': - description: - > - Bundled code, analysis, badge or JSON response. - - The URL breakdown is, - - ```ts - /? - q=(import)@okikio/emitter,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate& - treeshake=[T],[{ animate }],[{ animate as B }],[* as TR],[{ type animate }],[*],[{ animate as A }],[* as PR],[{ animate }]& - text="export * as PR18 from \"@okikio/animate\";\nexport { animate as animate2 } from \"@okikio/animate\";"& - share=MYewdgziA2CmB00QHMAUAiAwiG6CUQA& - config={"cdn":"skypack","compression":"brotli","esbuild":{"format":"cjs","minify":false,"treeShaking":false}}& - bundle - ``` - - * `q` or `query` represents the module, e.g. `react`, `vue`, etc... - You can add `(import)` in-front of a specific module to make it an import instead of an export - - * `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - - The square brackets represent seperate packages, and everything inside the squarebrackets, are the exported methods, types, etc... - - * `text` represents the input code as a string (it's used for short input code) - * `share` represents compressed string version of the input code (it's used for large input code) - * `config` represents the bundle configuration to use when building the bundle - * `bundle` tells **bundlejs** to bundle the input code on start-up. This isn't on by default for security reasons. I want to discourage people from sending large complex bundles that crash browsers or that take a long time to load, especially before the input code is properly verified as non-malicious. So, if you want to bundle the code on startup, you have to manually add `&bundle` to the end of the url yourself. - - The reason why I decided on this syntax is because it allows for a lot of flexibility, and transparency concerning what is being bundled. I also wanted to make it easy to share bundle session between users. - - You can add (import) in-front of a specific module to make it an import instead of an export, treeshake represents the export/imports to treeshake. - - The treeshake syntax allows for specifying multiple exports per package, through this syntax - content: - image/png: - schema: - type: string - format: binary - '400': - description: Bad request - '404': - description: Package not found - content: - text/plain: - schema: - type: string - example: "✘ [ERROR] [getRequest] Failed at request (https://unpkg.com/reac@0.0.0)\nError: Couldn't load https://unpkg.com/reac@0.0.0/index.js (404 code) [plugin cdn-url]\n\n virtual-filesystem:/index.ts:2:14:\n\n 2 │ export * from \"reac\";\n ╵ ~~~~~~" - '500': - description: Internal server error - - /file: - get: - operationId: getBundledFile - summary: Bundle, treeshake and minify JavaScript and TypeScript code and respond with the actual javascript bundle. - parameters: - - name: q - in: query - description: Represents the modules to bundle. - required: false - schema: - type: string - description: - > - `q` or `query` represents the module, e.g. react, vue, etc... - - You can add (import) in-front of a specific module to make it an import instead of an export - example: - > - react,react-dom,(import)spring-easing - - name: treeshake - in: query - description: - > - Represents the export/imports to treeshake. - Treeshake export object using this custom format - - `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - required: false - schema: - type: string - example: - > - [*],[{default}] - - name: file - in: query - description: Resulting bundled code. - required: false - schema: - type: boolean - - name: config - in: query - required: false - description: Bundlejs's Config in JSON form, with support for esbuild options in the esbuild field, and "package.json" in the "package.json" field - schema: - type: string - format: json - example: - > - { - "cdn": "https://unpkg.com", - "compression": "gzip", - "analysis": true, - "polyfill": false, - "tsx": false, - "package.json": { - "name": "bundled-code", - "version": "0.0.0", - "dependencies": { - "react": "^16" - } - }, - "esbuild": { - "target": [ - "esnext", - ], - "format": "esm", - "bundle": true, - "minify": true, - "treeShaking": true, - "platform": "browser", - "sourcemap": false, - "external": ["react"], - "alias": { - "react-dom": "https://esm.sh/react-dom" - } - }, - } - - name: polyfill - in: query - description: Polyfill Node built-ins. - required: false - schema: - type: boolean - - name: sourcemap - in: query - description: Esbuild sourcemap. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["inline", "external"] - - name: minify - in: query - description: Esbuild minify. - required: false - schema: - type: boolean - - name: format - in: query - description: Esbuild format. - required: false - schema: - type: string - enum: ["iife", "cjs", "esm"] - - name: text - in: query - description: Represents the input code as a string. - required: false - schema: - type: string - - name: share - in: query - description: Represents the compressed string version of the input code (compressed using @amoutonbrady/lz-string). - required: false - schema: - type: string - - name: tsx - in: query - description: Support JSX and TSX. - required: false - schema: - type: boolean - responses: - '200': - description: - > - Bundled code, analysis, badge or JSON response. - - The URL breakdown is, - - ```ts - /? - q=(import)@okikio/emitter,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate& - treeshake=[T],[{ animate }],[{ animate as B }],[* as TR],[{ type animate }],[*],[{ animate as A }],[* as PR],[{ animate }]& - text="export * as PR18 from \"@okikio/animate\";\nexport { animate as animate2 } from \"@okikio/animate\";"& - share=MYewdgziA2CmB00QHMAUAiAwiG6CUQA& - config={"cdn":"skypack","compression":"brotli","esbuild":{"format":"cjs","minify":false,"treeShaking":false}}& - bundle - ``` - - * `q` or `query` represents the module, e.g. `react`, `vue`, etc... - You can add `(import)` in-front of a specific module to make it an import instead of an export - - * `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - - The square brackets represent seperate packages, and everything inside the squarebrackets, are the exported methods, types, etc... - - * `text` represents the input code as a string (it's used for short input code) - * `share` represents compressed string version of the input code (it's used for large input code) - * `config` represents the bundle configuration to use when building the bundle - * `bundle` tells **bundlejs** to bundle the input code on start-up. This isn't on by default for security reasons. I want to discourage people from sending large complex bundles that crash browsers or that take a long time to load, especially before the input code is properly verified as non-malicious. So, if you want to bundle the code on startup, you have to manually add `&bundle` to the end of the url yourself. - - The reason why I decided on this syntax is because it allows for a lot of flexibility, and transparency concerning what is being bundled. I also wanted to make it easy to share bundle session between users. - - You can add (import) in-front of a specific module to make it an import instead of an export, treeshake represents the export/imports to treeshake. - - The treeshake syntax allows for specifying multiple exports per package, through this syntax - content: - application/javascript: - schema: - type: string - '400': - description: Bad request - '404': - description: Package not found - content: - text/plain: - schema: - type: string - example: "✘ [ERROR] [getRequest] Failed at request (https://unpkg.com/reac@0.0.0)\nError: Couldn't load https://unpkg.com/reac@0.0.0/index.js (404 code) [plugin cdn-url]\n\n virtual-filesystem:/index.ts:2:14:\n\n 2 │ export * from \"reac\";\n ╵ ~~~~~~" - '500': - description: Internal server error - - /analysis: - get: - operationId: getBundleAnalysis - summary: Bundle, treeshake and minify JavaScript and TypeScript code and respond with the bundle analysis, in esbuild analysis format. - parameters: - - name: q - in: query - description: Represents the modules to bundle. - required: false - schema: - type: string - description: - > - `q` or `query` represents the module, e.g. react, vue, etc... - - You can add (import) in-front of a specific module to make it an import instead of an export - example: - > - react,react-dom,(import)spring-easing - - name: treeshake - in: query - description: - > - Represents the export/imports to treeshake. - Treeshake export object using this custom format - - `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - required: false - schema: - type: string - example: - > - [*],[{default}] - - name: analysis - in: query - description: Esbuild generate visual analysis. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["verbose"] - - name: config - in: query - required: false - description: Bundlejs's Config in JSON form, with support for esbuild options in the esbuild field, and "package.json" in the "package.json" field - schema: - type: string - format: json - example: - > - { - "cdn": "https://unpkg.com", - "compression": "gzip", - "analysis": true, - "polyfill": false, - "tsx": false, - "package.json": { - "name": "bundled-code", - "version": "0.0.0", - "dependencies": { - "react": "^16" - } - }, - "esbuild": { - "target": [ - "esnext", - ], - "format": "esm", - "bundle": true, - "minify": true, - "treeShaking": true, - "platform": "browser", - "sourcemap": false, - "external": ["react"], - "alias": { - "react-dom": "https://esm.sh/react-dom" - } - }, - } - - name: polyfill - in: query - description: Polyfill Node built-ins. - required: false - schema: - type: boolean - - name: sourcemap - in: query - description: Esbuild sourcemap. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["inline", "external"] - - name: minify - in: query - description: Esbuild minify. - required: false - schema: - type: boolean - - name: format - in: query - description: Esbuild format. - required: false - schema: - type: string - enum: ["iife", "cjs", "esm"] - - name: text - in: query - description: Represents the input code as a string. - required: false - schema: - type: string - - name: share - in: query - description: Represents the compressed string version of the input code (compressed using @amoutonbrady/lz-string). - required: false - schema: - type: string - - name: tsx - in: query - description: Support JSX and TSX. - required: false - schema: - type: boolean - responses: - '200': - description: - > - Bundled code, analysis, badge or JSON response. - - The URL breakdown is, - - ```ts - /? - q=(import)@okikio/emitter,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate& - treeshake=[T],[{ animate }],[{ animate as B }],[* as TR],[{ type animate }],[*],[{ animate as A }],[* as PR],[{ animate }]& - text="export * as PR18 from \"@okikio/animate\";\nexport { animate as animate2 } from \"@okikio/animate\";"& - share=MYewdgziA2CmB00QHMAUAiAwiG6CUQA& - config={"cdn":"skypack","compression":"brotli","esbuild":{"format":"cjs","minify":false,"treeShaking":false}}& - bundle - ``` - - * `q` or `query` represents the module, e.g. `react`, `vue`, etc... - You can add `(import)` in-front of a specific module to make it an import instead of an export - - * `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - - The square brackets represent seperate packages, and everything inside the squarebrackets, are the exported methods, types, etc... - - * `text` represents the input code as a string (it's used for short input code) - * `share` represents compressed string version of the input code (it's used for large input code) - * `config` represents the bundle configuration to use when building the bundle - * `bundle` tells **bundlejs** to bundle the input code on start-up. This isn't on by default for security reasons. I want to discourage people from sending large complex bundles that crash browsers or that take a long time to load, especially before the input code is properly verified as non-malicious. So, if you want to bundle the code on startup, you have to manually add `&bundle` to the end of the url yourself. - - The reason why I decided on this syntax is because it allows for a lot of flexibility, and transparency concerning what is being bundled. I also wanted to make it easy to share bundle session between users. - - You can add (import) in-front of a specific module to make it an import instead of an export, treeshake represents the export/imports to treeshake. - - The treeshake syntax allows for specifying multiple exports per package, through this syntax - content: - text/plain: - schema: - type: string - example: "index.js 12.9kb 100.0%\n ├ http-url:https://unpkg.com/react@18.2.0/cjs/react.production.min.js 6.3kb 49.0%\n ├ http-url:https://unpkg.com/spring-easing@2.1.2/lib/index.mjs 4.3kb 33.1%\n ├ http-url:https://unpkg.com/react@18.2.0/index.js 50b 0.4%\n â”” virtual-filesystem:/index.ts 16b 0.1%" - '400': - description: Bad request - '404': - description: Package not found - content: - text/plain: - schema: - type: string - example: "✘ [ERROR] [getRequest] Failed at request (https://unpkg.com/reac@0.0.0)\nError: Couldn't load https://unpkg.com/reac@0.0.0/index.js (404 code) [plugin cdn-url]\n\n virtual-filesystem:/index.ts:2:14:\n\n 2 │ export * from \"reac\";\n ╵ ~~~~~~" - '500': - description: Internal server error - - /metafile: - get: - operationId: getBundleMetafileFromEsbuild - summary: Bundle, treeshake and minify JavaScript and TypeScript code and respond with it's metafile. - parameters: - - name: q - in: query - description: Represents the modules to bundle. - required: false - schema: - type: string - description: - > - `q` or `query` represents the module, e.g. react, vue, etc... - - You can add (import) in-front of a specific module to make it an import instead of an export - example: - > - react,react-dom,(import)spring-easing - - name: treeshake - in: query - description: - > - Represents the export/imports to treeshake. - Treeshake export object using this custom format - - `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - required: false - schema: - type: string - example: - > - [*],[{default}] - - name: metafile - in: query - description: Esbuild bundle metafile. - required: false - schema: - type: boolean - - name: config - in: query - required: false - description: Bundlejs's Config in JSON form, with support for esbuild options in the esbuild field, and "package.json" in the "package.json" field - schema: - type: string - format: json - example: - > - { - "cdn": "https://unpkg.com", - "compression": "gzip", - "analysis": true, - "polyfill": false, - "tsx": false, - "package.json": { - "name": "bundled-code", - "version": "0.0.0", - "dependencies": { - "react": "^16" - } - }, - "esbuild": { - "target": [ - "esnext", - ], - "format": "esm", - "bundle": true, - "minify": true, - "treeShaking": true, - "platform": "browser", - "sourcemap": false, - "external": ["react"], - "alias": { - "react-dom": "https://esm.sh/react-dom" - } - }, - } - - name: polyfill - in: query - description: Polyfill Node built-ins. - required: false - schema: - type: boolean - - name: sourcemap - in: query - description: Esbuild sourcemap. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["inline", "external"] - - name: minify - in: query - description: Esbuild minify. - required: false - schema: - type: boolean - - name: format - in: query - description: Esbuild format. - required: false - schema: - type: string - enum: ["iife", "cjs", "esm"] - - name: warnings - in: query - description: Lists warnings for a particular bundle. - required: false - schema: - type: boolean - - name: share - in: query - description: Represents the compressed string version of the input code (compressed using @amoutonbrady/lz-string). - required: false - schema: - type: string - - name: tsx - in: query - description: Support JSX and TSX. - required: false - schema: - type: boolean - responses: - '200': - description: - > - Bundled code, analysis, badge or JSON response. - - The URL breakdown is, - - ```ts - /? - q=(import)@okikio/emitter,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate& - treeshake=[T],[{ animate }],[{ animate as B }],[* as TR],[{ type animate }],[*],[{ animate as A }],[* as PR],[{ animate }]& - text="export * as PR18 from \"@okikio/animate\";\nexport { animate as animate2 } from \"@okikio/animate\";"& - share=MYewdgziA2CmB00QHMAUAiAwiG6CUQA& - config={"cdn":"skypack","compression":"brotli","esbuild":{"format":"cjs","minify":false,"treeShaking":false}}& - bundle - ``` - - * `q` or `query` represents the module, e.g. `react`, `vue`, etc... - You can add `(import)` in-front of a specific module to make it an import instead of an export - - * `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - - The square brackets represent seperate packages, and everything inside the squarebrackets, are the exported methods, types, etc... - - * `text` represents the input code as a string (it's used for short input code) - * `share` represents compressed string version of the input code (it's used for large input code) - * `config` represents the bundle configuration to use when building the bundle - * `bundle` tells **bundlejs** to bundle the input code on start-up. This isn't on by default for security reasons. I want to discourage people from sending large complex bundles that crash browsers or that take a long time to load, especially before the input code is properly verified as non-malicious. So, if you want to bundle the code on startup, you have to manually add `&bundle` to the end of the url yourself. - - The reason why I decided on this syntax is because it allows for a lot of flexibility, and transparency concerning what is being bundled. I also wanted to make it easy to share bundle session between users. - - You can add (import) in-front of a specific module to make it an import instead of an export, treeshake represents the export/imports to treeshake. - - The treeshake syntax allows for specifying multiple exports per package, through this syntax - content: - text/plain: - schema: - type: string - example: "index.js 12.9kb 100.0%\n ├ http-url:https://unpkg.com/react@18.2.0/cjs/react.production.min.js 6.3kb 49.0%\n ├ http-url:https://unpkg.com/spring-easing@2.1.2/lib/index.mjs 4.3kb 33.1%\n ├ http-url:https://unpkg.com/react@18.2.0/index.js 50b 0.4%\n â”” virtual-filesystem:/index.ts 16b 0.1%" - '400': - description: Bad request - '404': - description: Package not found - content: - text/plain: - schema: - type: string - example: "✘ [ERROR] [getRequest] Failed at request (https://unpkg.com/reac@0.0.0)\nError: Couldn't load https://unpkg.com/reac@0.0.0/index.js (404 code) [plugin cdn-url]\n\n virtual-filesystem:/index.ts:2:14:\n\n 2 │ export * from \"reac\";\n ╵ ~~~~~~" - '500': - description: Internal server error - - /warnings: - get: - operationId: getBundleWarnings - summary: Bundle, treeshake and minify JavaScript and TypeScript code and respond with a list of warnings that may potentially causes issues. - parameters: - - name: q - in: query - description: Represents the modules to bundle. - required: false - schema: - type: string - description: - > - `q` or `query` represents the module, e.g. react, vue, etc... - - You can add (import) in-front of a specific module to make it an import instead of an export - example: - > - react,react-dom,(import)spring-easing - - name: treeshake - in: query - description: - > - Represents the export/imports to treeshake. - Treeshake export object using this custom format - - `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - required: false - schema: - type: string - example: - > - [*],[{default}] - - name: file - in: query - description: Resulting bundled code. - required: false - schema: - type: boolean - - name: config - in: query - required: false - description: Bundlejs's Config in JSON form, with support for esbuild options in the esbuild field, and "package.json" in the "package.json" field - schema: - type: string - format: json - example: - > - { - "cdn": "https://unpkg.com", - "compression": "gzip", - "analysis": true, - "polyfill": false, - "tsx": false, - "package.json": { - "name": "bundled-code", - "version": "0.0.0", - "dependencies": { - "react": "^16" - } - }, - "esbuild": { - "target": [ - "esnext", - ], - "format": "esm", - "bundle": true, - "minify": true, - "treeShaking": true, - "platform": "browser", - "sourcemap": false, - "external": ["react"], - "alias": { - "react-dom": "https://esm.sh/react-dom" - } - }, - } - - name: polyfill - in: query - description: Polyfill Node built-ins. - required: false - schema: - type: boolean - - name: sourcemap - in: query - description: Esbuild sourcemap. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["inline", "external"] - - name: minify - in: query - description: Esbuild minify. - required: false - schema: - type: boolean - - name: format - in: query - description: Esbuild format. - required: false - schema: - type: string - enum: ["iife", "cjs", "esm"] - - name: text - in: query - description: Represents the input code as a string. - required: false - schema: - type: string - - name: share - in: query - description: Represents the compressed string version of the input code (compressed using @amoutonbrady/lz-string). - required: false - schema: - type: string - - name: tsx - in: query - description: Support JSX and TSX. - required: false - schema: - type: boolean - responses: - '200': - description: - > - Bundled code, analysis, badge or JSON response. - - The URL breakdown is, - - ```ts - /? - q=(import)@okikio/emitter,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate& - treeshake=[T],[{ animate }],[{ animate as B }],[* as TR],[{ type animate }],[*],[{ animate as A }],[* as PR],[{ animate }]& - text="export * as PR18 from \"@okikio/animate\";\nexport { animate as animate2 } from \"@okikio/animate\";"& - share=MYewdgziA2CmB00QHMAUAiAwiG6CUQA& - config={"cdn":"skypack","compression":"brotli","esbuild":{"format":"cjs","minify":false,"treeShaking":false}}& - bundle - ``` - - * `q` or `query` represents the module, e.g. `react`, `vue`, etc... - You can add `(import)` in-front of a specific module to make it an import instead of an export - - * `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - - The square brackets represent seperate packages, and everything inside the squarebrackets, are the exported methods, types, etc... - - * `text` represents the input code as a string (it's used for short input code) - * `share` represents compressed string version of the input code (it's used for large input code) - * `config` represents the bundle configuration to use when building the bundle - * `bundle` tells **bundlejs** to bundle the input code on start-up. This isn't on by default for security reasons. I want to discourage people from sending large complex bundles that crash browsers or that take a long time to load, especially before the input code is properly verified as non-malicious. So, if you want to bundle the code on startup, you have to manually add `&bundle` to the end of the url yourself. - - The reason why I decided on this syntax is because it allows for a lot of flexibility, and transparency concerning what is being bundled. I also wanted to make it easy to share bundle session between users. - - You can add (import) in-front of a specific module to make it an import instead of an export, treeshake represents the export/imports to treeshake. - - The treeshake syntax allows for specifying multiple exports per package, through this syntax - content: - text/plain: - schema: - type: string - '400': - description: Bad request - '404': - description: Package not found - content: - text/plain: - schema: - type: string - example: "✘ [ERROR] [getRequest] Failed at request (https://unpkg.com/reac@0.0.0)\nError: Couldn't load https://unpkg.com/reac@0.0.0/index.js (404 code) [plugin cdn-url]\n\n virtual-filesystem:/index.ts:2:14:\n\n 2 │ export * from \"reac\";\n ╵ ~~~~~~" - '500': - description: Internal server error - - /raw: - get: - operationId: getRawBundleMetadataAsJSON - summary: Bundle, treeshake and minify JavaScript and TypeScript code and respond with a JSON object listing the raw metadata with no extra processing. - parameters: - - name: q - in: query - description: Represents the modules to bundle. - required: false - schema: - type: string - description: - > - `q` or `query` represents the module, e.g. react, vue, etc... - - You can add (import) in-front of a specific module to make it an import instead of an export - example: - > - react,react-dom,(import)spring-easing - - name: treeshake - in: query - description: - > - Represents the export/imports to treeshake. - Treeshake export object using this custom format - - `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - required: false - schema: - type: string - example: - > - [*],[{default}] - - name: config - in: query - required: false - description: Bundlejs's Config in JSON form, with support for esbuild options in the esbuild field, and "package.json" in the "package.json" field - schema: - type: string - format: json - example: - > - { - "cdn": "https://unpkg.com", - "compression": "gzip", - "analysis": true, - "polyfill": false, - "tsx": false, - "package.json": { - "name": "bundled-code", - "version": "0.0.0", - "dependencies": { - "react": "^16" - } - }, - "esbuild": { - "target": [ - "esnext", - ], - "format": "esm", - "bundle": true, - "minify": true, - "treeShaking": true, - "platform": "browser", - "sourcemap": false, - "external": ["react"], - "alias": { - "react-dom": "https://esm.sh/react-dom" - } - }, - } - - name: polyfill - in: query - description: Polyfill Node built-ins. - required: false - schema: - type: boolean - - name: sourcemap - in: query - description: Esbuild sourcemap. - required: false - schema: - oneOf: - - type: boolean - - type: string - enum: ["inline", "external"] - - name: minify - in: query - description: Esbuild minify. - required: false - schema: - type: boolean - - name: format - in: query - description: Esbuild format. - required: false - schema: - type: string - enum: ["iife", "cjs", "esm"] - - name: raw - in: query - description: The raw result of the bundle. - required: false - schema: - type: boolean - - name: text - in: query - description: Represents the input code as a string. - required: false - schema: - type: string - - name: share - in: query - description: Represents the compressed string version of the input code (compressed using @amoutonbrady/lz-string). - required: false - schema: - type: string - - name: tsx - in: query - description: Support JSX and TSX. - required: false - schema: - type: boolean - responses: - '200': - description: - > - Bundled code, analysis, badge or JSON response. - - The URL breakdown is, - - ```ts - /? - q=(import)@okikio/emitter,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate,@okikio/animate& - treeshake=[T],[{ animate }],[{ animate as B }],[* as TR],[{ type animate }],[*],[{ animate as A }],[* as PR],[{ animate }]& - text="export * as PR18 from \"@okikio/animate\";\nexport { animate as animate2 } from \"@okikio/animate\";"& - share=MYewdgziA2CmB00QHMAUAiAwiG6CUQA& - config={"cdn":"skypack","compression":"brotli","esbuild":{"format":"cjs","minify":false,"treeShaking":false}}& - bundle - ``` - - * `q` or `query` represents the module, e.g. `react`, `vue`, etc... - You can add `(import)` in-front of a specific module to make it an import instead of an export - - * `treeshake` represents the export/imports to treeshake. - The treeshake syntax allows for specifying multiple exports per package, through this syntax - - ```ts - "[{ x,y,z }],[*],[* as X],[{ type xyz }]" - // to - export { x, y, z } from "..."; - export * from "..."; - export * as X from "..."; - export { type xyz } from "..."; - ``` - - The square brackets represent seperate packages, and everything inside the squarebrackets, are the exported methods, types, etc... - - * `text` represents the input code as a string (it's used for short input code) - * `share` represents compressed string version of the input code (it's used for large input code) - * `config` represents the bundle configuration to use when building the bundle - * `bundle` tells **bundlejs** to bundle the input code on start-up. This isn't on by default for security reasons. I want to discourage people from sending large complex bundles that crash browsers or that take a long time to load, especially before the input code is properly verified as non-malicious. So, if you want to bundle the code on startup, you have to manually add `&bundle` to the end of the url yourself. - - The reason why I decided on this syntax is because it allows for a lot of flexibility, and transparency concerning what is being bundled. I also wanted to make it easy to share bundle session between users. - - You can add (import) in-front of a specific module to make it an import instead of an export, treeshake represents the export/imports to treeshake. - - The treeshake syntax allows for specifying multiple exports per package, through this syntax - content: - application/json: - schema: - type: object - properties: - query: - type: string - rawQuery: - type: string - version: - type: string - nullable: true - versions: - type: array - items: - type: string - nullable: true - config: - type: object - additionalProperties: true - input: - type: string - size: - type: object - properties: - type: - type: string - rawUncompressedSize: - type: integer - uncompressedSize: - type: string - rawCompressedSize: - type: integer - compressedSize: - type: string - size: - type: string - time: - type: string - rawTime: - type: integer - fileId: - type: string - fileUrl: - type: string - fileHTMLUrl: - type: string - '400': - description: Bad request - '404': - description: Package not found - content: - text/plain: - schema: - type: string - example: "✘ [ERROR] [getRequest] Failed at request (https://unpkg.com/reac@0.0.0)\nError: Couldn't load https://unpkg.com/reac@0.0.0/index.js (404 code) [plugin cdn-url]\n\n virtual-filesystem:/index.ts:2:14:\n\n 2 │ export * from \"reac\";\n ╵ ~~~~~~" - '500': - description: Internal server error - \ No newline at end of file diff --git a/packages/edge/constants.ts b/packages/edge/constants.ts new file mode 100644 index 0000000..c976871 --- /dev/null +++ b/packages/edge/constants.ts @@ -0,0 +1,11 @@ +export const headers = Object.entries({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET" +}); + +export const PACKAGE_RESULT_PREFIX = "json-package"; + +export const inputModelResetValue = [ + 'export * from "spring-easing";', + 'export { default } from "spring-easing";' +].join("\n"); \ No newline at end of file diff --git a/edge/deno.jsonc b/packages/edge/deno.jsonc similarity index 100% rename from edge/deno.jsonc rename to packages/edge/deno.jsonc diff --git a/packages/edge/execute.ts b/packages/edge/execute.ts new file mode 100644 index 0000000..ae964da --- /dev/null +++ b/packages/edge/execute.ts @@ -0,0 +1,44 @@ +import ESBUILD_WASM from "@bundle/core/wasm"; + +import { bundle } from "./bundle.ts"; +import type { Config, PreparedBundleRequest } from "./types.ts"; + +let wasmSource: Uint8Array | undefined; +let compiledWasmModule: WebAssembly.Module | undefined; + +export async function getBundleWasmModule(): Promise { + if (!wasmSource) { + wasmSource = await ESBUILD_WASM(); + } + + if (!compiledWasmModule) { + compiledWasmModule = new WebAssembly.Module(wasmSource as BufferSource); + } + + return compiledWasmModule; +} + +export async function createBundleConfig(preparedRequest: PreparedBundleRequest): Promise { + const wasmModule = await getBundleWasmModule(); + + return Object.assign({}, preparedRequest.jsonKeyObject, { + entryPoints: [`/index.${preparedRequest.bundleKey}${preparedRequest.useTsxEntrypoint ? ".tsx" : ".ts"}`], + init: { + ...preparedRequest.jsonKeyObject.init, + wasmModule + } + }) as Config; +} + +export async function executePreparedBundle(url: URL, preparedRequest: PreparedBundleRequest) { + const config = await createBundleConfig(preparedRequest); + + return bundle( + url, + preparedRequest.initialValue, + config, + preparedRequest.versions, + preparedRequest.modules, + preparedRequest.query + ); +} \ No newline at end of file diff --git a/packages/edge/legacy/bundle.ts b/packages/edge/legacy/bundle.ts new file mode 100644 index 0000000..c82b0c3 --- /dev/null +++ b/packages/edge/legacy/bundle.ts @@ -0,0 +1,134 @@ +import type { createDefaultFileSystem, ESBUILD } from "@bundle/core"; +import type { BundleModule, Config } from "./types.ts"; +import { headers } from "./constants.ts"; + + +import { compress } from "@bundle/compress"; +import { build, setFile, useFileSystem, createConfig, createNotice } from "@bundle/core"; +import { debounce, resolveVersion } from "@bundle/utils"; + +const FileSystem = useFileSystem(); +export const timeFormatter = new Intl.RelativeTimeFormat("en", { + style: "narrow", + numeric: "auto", +}); + +export type BundleResult = { + query: string, + rawQuery: string, + config: Config + input: string, + version?: string, + versions?: string[], + modules?: BundleModule[], + size: Omit>, "content">, + time: string, + rawTime: number, + fileId?: string, + fileUrl?: string, + fileHTMLUrl?: string, + warnings?: string[], + metafile?: ESBUILD.Metafile +} + +export async function bundle(url: URL, initialValue: string, configObj: Config, versions: string[], modules: BundleModule[], query: string) { + const fs = await FileSystem; + const start = performance.now(); + const versionsArr = Array.from(new Set(versions)) + const modulesArr = Array.from(new Set(modules)) + + const { entryPoints = ["/index.ts"] } = configObj; + + if (Array.isArray(entryPoints)) { + setFile(fs, (entryPoints as string[])[0], initialValue); + } else if (typeof entryPoints === "string") { + setFile(fs, entryPoints as string, initialValue); + } else { + setFile(fs, entryPoints.in, initialValue); + } + + console.log({ + configObj + }) + + const metafileQuery = url.searchParams.has("metafile"); + const analysisQuery = url.searchParams.has("analysis") || + url.searchParams.has("analyze"); + + const enableMetafile = analysisQuery || + metafileQuery || + Boolean(configObj?.esbuild?.metafile); + + const result = await build(configObj, FileSystem); + const end = performance.now(); + + await fs?.clear?.(); + + let resultValue: string = result.contents[0].text; + const entryPointInputFile = Array.isArray(entryPoints) ? entryPoints[0] + : "in" in entryPoints ? entryPoints.in + : entryPoints + const { content: _content, ...size } = await compress( + result.contents.map((x: { contents: Uint8Array; path: string; text: string }) => { + if (x.path === entryPointInputFile) resultValue = x.text; + return x.contents + }), + configObj.compression + ); + + const { init: _init, ...printableConfig } = createConfig("build", configObj); + const duration = (end - start); + + const [ + gistDetails, + warnings + ] = (await Promise.allSettled([ + null, // setGist(url.href, result.outputs), + createNotice(result.warnings, "warning", false), + ])).map(res => { + if (res?.status === "fulfilled") { + return res?.value; + } + + return null; + }); + + // const { fileId, fileUrl, fileHTMLUrl } = gistDetails ?? {}; + + const searchQueries = url.search || `?q=${query}`; + const finalResult: BundleResult = Object.assign({ + query: decodeURIComponent(searchQueries), + rawQuery: encodeURIComponent(searchQueries), + }, + (versionsArr.length === 1 ? { version: versionsArr[0] } : { versions: versionsArr }), + { + modules: modulesArr, + config: printableConfig, + input: initialValue, + size, + installSize: { + total: result?.totalInstallSize, + packages: result?.packageSizeArr, + }, + time: timeFormatter.format(duration / 1000, "seconds"), + rawTime: duration + }, + // (gistDetails && fileId ? { fileId } : null), + // (gistDetails && fileUrl ? { fileUrl } : null), + // (gistDetails && fileHTMLUrl ? { fileHTMLUrl } : null), + (result?.warnings?.length > 0 ? { warnings } : null), + (enableMetafile && result?.metafile ? { metafile: result?.metafile } : null) + ); + + return [ + new Response(JSON.stringify(finalResult), { + status: 200, + headers: [ + ...headers, + ['Cache-Control', 'max-age=30, s-maxage=30, public'], + ['Content-Type', 'application/json'] + ], + }), + resultValue + ] as const +} \ No newline at end of file diff --git a/packages/edge/legacy/generate-result.ts b/packages/edge/legacy/generate-result.ts new file mode 100644 index 0000000..d40367f --- /dev/null +++ b/packages/edge/legacy/generate-result.ts @@ -0,0 +1,304 @@ +import type { CompressionType } from "@bundle/compress"; +import type { BundleResult } from "./bundle.ts"; + +import { encodeBase64 } from "@bundle/utils/encoding"; + +import { ansi } from "@bundle/utils"; + +import { LOGGER_INFO, dispatchEvent, getEsbuild } from "@bundle/core"; +import { headers } from "./constants.ts"; +import styleText from "./style.ts";; + +export type StoredBadge = { + body: string; + contentType: string; + encoding: "text" | "base64"; +}; + +export type ResultStorage = { + readFile?: (value: BundleResult, resultText?: string) => Promise; + setBadge?: (badgeKey: string, badgeID: string, badge: StoredBadge) => Promise; +}; + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(arrayBuffer).set(bytes); + return arrayBuffer; +} + +export const timeFormatter = new Intl.RelativeTimeFormat("en", { + style: "narrow", + numeric: "auto", +}) + +export const docs = { + docs: `/?docs - Takes you to some docs for the API`, + examples: [ + "(new) /?tsx or /?jsx", + "(new) /?badge or /?badge=detailed or /?badge=minified", + "(new) /?badge-style=for-the-badge", + "(new) /?badge-raster", + "(new) /?file", + "(new) /?polyfill", + `(new) /?analysis or /?analyze=verbose`, + "(new) /?metafile", + `(new) /?minify=false`, + `(new) /?sourcemap=inline`, + `(new) /?format=iife`, + `(new) /?warnings`, + `(new) /?raw`, + "~~~", + `/?q=spring-easing,(import)@okikio/emitter,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,(import)@okikio/animate,@okikio/animate,typescript@beta,vue,react`, + `/?treeshake=[SpringEasing],[T],[{ animate }],[{ animate as B }],[* as TR],[{ type animate }],[*],[*],[*],[*]`, + `/?text="export * as PR18 from \"@okikio/animate\";\nexport { animate as animate2 } from \"@okikio/animate\";"`, + `/?share=MYewdgziA2CmB00QHMAUAiAwiG6CUQA`, + `/?config={"cdn":"skypack","compression":"brotli","esbuild":{"format":"cjs","minify":false,"treeShaking":false}}`, + ], + basics: [ + "(new) /?tsx or /?jsx - Support JSX and TSX. Used to be built-in but decided to make it optional, as it caused errors in non TSX packages", + `(new) /?badge - Generates a badge (if you want more details, set \`?badge=detailed\` (to list the modules being bundled in the badge) or \`?badge=minified\` for the minified bundle size)`, + `(new) /?badge-style - Various badge styles supported by http://shields.io (https://shields.io/#:~:text=PREFIX%3E%26suffix%3D%3CSUFFIX%3E-,Styles,-The%20following%20styles)`, + `(new) /?badge-raster - The badge but as a png image`, + `(new) /?file - Resulting bundled code(you can actually import this into your javascript file and start using it https://stackblitz.com/edit/vitejs-vite-iquaht?file=src%2Fmain.ts&terminal=dev)`, + `(new) /?polyfill - Polyfill Node built-ins`, + `(new) /?analysis or /?analyze - Esbuild generate visual analysis https://esbuild.github.io/api/#analyze`, + `(new) /?metafile - Esbuild bundle metafile which can be used w / https://esbuild.github.io/analyze/ (hoping to have this built-in in the future)`, + `(new) /?minify - Esbuild minify https://esbuild.github.io/api/#minify`, + `(new) /?sourcemap - Esbuild sourcemap https://esbuild.github.io/api/#source-maps`, + `(new) /?format - Esbuild format https://esbuild.github.io/api/#format`, + `(new) /?warnings - Lists warning for a particular bundle`, + `(new) /?raw - The raw result of the bundle (meant for experiments and/or testing)`, + "~~~", + `/?q or /?query - Represents the module, e.g. react, vue, etc... You can add (import) in-front of a specific module to make it an import instead of an export`, + `/?treeshake - Represents the export/imports to treeshake. The treeshake syntax allows for specifying multiple exports per package (check the example above). The square brackets represent seperate packages, and everything inside the square brackets, are the exported methods, types, etc...`, + `/?text - Represents the input code as a string (it's meant for short strings, we recommend using \`/?share\` for longer strings)`, + `/?share - Represents \`compressed\` string version of the input code (it's used for large input code)`, + `/?config - Represents the configurations to use when building the bundle (the docs cover the config in detail https://blog.okikio.dev/documenting-an-online-bundler-bundlejs#heading-configuration)`, + ], +} + +function sanitizeShieldsIO(str: string) { + return str + .replace(/\-/g, "--") + .replace(/\_/g, "__") + .replace(/\s/g, "_") +} + +export async function generateResult([badgeKey, badgeID]: string[], [value, resultText]: [BundleResult, string | undefined], url: URL, cached: boolean, duration: number, storage?: ResultStorage) { + const noCache = ["/no-cache", "/clear-cache", "/delete-cache"].includes(url.pathname); + const event_key = cached ? "cached-json-" : "json-"; + + const analysisQuery = url.searchParams.has("analysis") || + url.searchParams.has("analyze") || + ["/analysis", "/analyze"].includes(url.pathname); + + const analysisResult = url.searchParams.get("analysis") || + url.searchParams.get("analyze"); + + const metafileQuery = url.searchParams.has("metafile") || + url.pathname === "/metafile"; + const fileQuery = url.searchParams.has("file") || url.pathname === "/file"; + + const badgeQuery = url.searchParams.has("badge") || ["/badge", "/badge/raster", "/badge-raster"].includes(url.pathname); + const warningsQuery = url.searchParams.has("warnings") || + url.searchParams.has("warning") || ["/warnings"].includes(url.pathname); + + const rawQuery = url.searchParams.has("raw") || url.pathname === "/raw"; + + const badgeResult = url.searchParams.get("badge"); + const badgeStyle = url.searchParams.get("badge-style"); + + const badgeRasterQuery = url.searchParams.has("badge-raster") || url.searchParams.has("png") || ["/badge/raster", "/badge-raster"].includes(url.pathname); + const query = ( + url.searchParams.get("q") || + url.searchParams.get("query") + ) || "spring-easing"; + + const queries = { + query, + analysisQuery, + analysisResult, + metafileQuery, + fileQuery, + badgeQuery, + warningsQuery, + rawQuery, + badgeResult, + badgeStyle, + badgeRasterQuery, + } + + if (badgeQuery) { + const { size } = value; + const uncompressedBadge = /uncompress/.exec(badgeResult ?? ""); + const minifiedBadge = /minify|minified/.exec(badgeResult ?? ""); + const detailedBadge = /detail/.exec(badgeResult ?? ""); + + const urlQuery = encodeURIComponent(`https://bundlejs.com/${url.search}`); + const detailBadgeText = sanitizeShieldsIO( + detailedBadge ? `${size.uncompressedSize} -> ` : "" + ); + const detailBadgeName = sanitizeShieldsIO( + `bundlejs${detailedBadge ? ` (${value.modules?.map(([p]) => p)?.join(", ") ?? query})` : ""}` + ); + + let badgeType: CompressionType | "minified" | "uncompressed" | undefined = size.type; + let badgeBundleSize: string = size.compressedSize; + + if (minifiedBadge) { + badgeType = "minified"; + badgeBundleSize = size.uncompressedSize; + } else if (uncompressedBadge) { + badgeType = "uncompressed"; + badgeBundleSize = size.uncompressedSize; + } + + const imgUrl = new URL( + `https://${badgeRasterQuery ? "raster.shields.io" : "img.shields.io"}/badge/${detailBadgeText}${sanitizeShieldsIO(`${badgeBundleSize} (${badgeType})`)}-${detailBadgeName}-blue?link=${urlQuery}` + ); + + if (badgeStyle) { imgUrl.searchParams.append("style", badgeStyle); } + dispatchEvent(LOGGER_INFO, imgUrl.href) + + const imgFetch = await fetch(imgUrl); + if (!imgFetch.ok) return imgFetch; + + const imgShield = badgeRasterQuery ? new Uint8Array(await imgFetch.arrayBuffer()) : await imgFetch.text(); + + try { + await storage?.setBadge?.(badgeKey, badgeID, { + body: typeof imgShield === "string" ? imgShield : encodeBase64(imgShield), + contentType: badgeRasterQuery ? "image/png" : "image/svg+xml", + encoding: typeof imgShield === "string" ? "text" : "base64" + }); + } catch (e) { + console.warn(e); + } + + return new Response(typeof imgShield === "string" ? imgShield : toArrayBuffer(imgShield), { + status: 200, + headers: [ + ...headers, + ['Cache-Control', `max-age=${30}, public`], + ['Content-Type', badgeRasterQuery ? "image/png" : 'image/svg+xml'] + ], + }) + } + + if (fileQuery) { + const { fileId } = value; + let fileResult = await storage?.readFile?.(value, resultText); + + if (fileResult === undefined && fileId) { + const { getFile } = await import("./gist.ts"); + fileResult = await getFile(fileId); + } + + if (fileResult === undefined && resultText !== undefined) { + fileResult = resultText; + } + + if (fileResult === undefined) { + throw new Error("The fileId was empty 🤔, hmm...maybe try again later, if this error persists please create an issue on https://github.com/okikio/bundlejs.") + } + + if (fileResult === undefined) { + throw new Error("Whoops we can't quite find the file you're looking for, please create an issue on https://github.com/okikio/bundlejs.") + } + + return new Response(typeof fileResult === "string" ? fileResult : toArrayBuffer(fileResult), { + status: 200, + headers: [ + ...headers, + ['Cache-Control', `max-age=${noCache ? 30 : 720}, public`], + ['Content-Type', 'text/javascript'] + ], + }) + } + + if (analysisQuery && value.metafile) { + const { analyzeMetafile } = await getEsbuild(); + const verboseAnlysis = analysisResult === "verbose"; + + return new Response( + generateHTMLMessages([ + ansi( + ( + await analyzeMetafile(value.metafile, { + color: true, + verbose: verboseAnlysis + }) + ) + ) + ]), + { + status: 200, + headers: [ + ...headers, + ['Cache-Control', `max-age=${noCache ? 30 : 180}, public`], + ['Content-Type', 'text/html'] + ], + } + ) + } + + if (metafileQuery && value.metafile) { + return new Response(JSON.stringify(value.metafile), { + status: 200, + headers: [ + ...headers, + ['Cache-Control', `max-age=${noCache ? 30 : 180}, public`], + ['Content-Type', 'application/json'] + ], + }) + } + + if (warningsQuery) { + return new Response(generateHTMLMessages(value.warnings ?? ["No warnings for this bundle"]), + { + status: 200, + headers: [ + ...headers, + ['Cache-Control', `max-age=30, public`], + ['Content-Type', 'text/html'] + ] + } + ) + } + + if (rawQuery) { + return new Response(JSON.stringify(value), { + status: 200, + headers: [ + ...headers, + ['Cache-Control', `max-age=30, public`], + ['Content-Type', 'application/json'] + ], + }); + } + + const { metafile: _metafile, warnings: _warnings, ...usefulInfo } = value; + const addDocs = (url.search === "" ? docs : ""); + const finalResult = Object.assign({}, usefulInfo, addDocs, + cached ? { + time: timeFormatter.format(duration / 1000, "seconds"), + rawTime: duration + } : null + ); + + return new Response(JSON.stringify(finalResult), { + status: 200, + headers: [ + ...headers, + ['Cache-Control', 'max-age=720, public'], + ['Content-Type', 'application/json'] + ], + }) +} + +export function generateHTMLMessages(msgs: string[]) { + return [ + ``, + `

${msgs.join("\n")}
` + ].join(""); +} \ No newline at end of file diff --git a/edge/legacy/gist.ts b/packages/edge/legacy/gist.ts similarity index 100% rename from edge/legacy/gist.ts rename to packages/edge/legacy/gist.ts diff --git a/edge/mod.ts b/packages/edge/mod.ts similarity index 98% rename from edge/mod.ts rename to packages/edge/mod.ts index 760b22c..bc9f2ca 100644 --- a/edge/mod.ts +++ b/packages/edge/mod.ts @@ -74,4 +74,4 @@ export type BundleFileOutput = typeof BundleFileDef.Output // ... (similar for other endpoints) -export default EndpointDefinitions \ No newline at end of file +export default EndpointDefinitions diff --git a/packages/edge/request.ts b/packages/edge/request.ts new file mode 100644 index 0000000..ff2f255 --- /dev/null +++ b/packages/edge/request.ts @@ -0,0 +1,195 @@ +import type { PackageJson } from "@bundle/utils/types"; +import type { BundleKeyObject, BundleModule, Config, PreparedBundleRequest } from "./types.ts"; + +import JSON5 from "./vendor/json5.ts"; + +import { createCompressConfig } from "@bundle/compress"; +import { deepMerge, parsePackageName, resolveVersion } from "@bundle/utils"; +import { BUILD_CONFIG } from "@bundle/core"; + +import { inputModelResetValue, PACKAGE_RESULT_PREFIX } from "./constants.ts"; +import { parseConfig, parseShareURLQuery, parseTreeshakeExports } from "./parse-query.ts"; + +export function convertQueryValue(str?: string | null) { + if (str === "false") return false; + if (str === "true") return true; + return str; +} + +export async function hashString(str: string) { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + + return hashArray.map(byte => byte.toString(16).padStart(2, "0")).join(""); +} + +export function getPackageResultKey(str: string) { + return `${PACKAGE_RESULT_PREFIX}/${str}`; +} + +export async function prepareBundleRequest(url: URL): Promise { + const initialValue = parseShareURLQuery(url) || inputModelResetValue; + const { init: _init, entryPoints: _entryPoints, ansi: _ansi, ...initialConfig } = (parseConfig(url) || {}) as Config; + + const configQuery = url.searchParams.get("config"); + const treeshakeQuery = url.searchParams.has("treeshake"); + const treeshake = url.searchParams.get("treeshake"); + const treeshakeArr = parseTreeshakeExports( + decodeURIComponent(treeshake ?? "") + .trim() + .replace(/\s{2,}/, " ") + ).map(value => value.trim()); + + const uniqueTreeshakeArr = Array.from(new Set(treeshakeArr)); + const exportAll = !treeshakeQuery || uniqueTreeshakeArr.every(value => /\*|{\s?default\s?}/.test(value)); + + const metafileQuery = url.searchParams.has("metafile") || url.pathname === "/metafile"; + const analysisQuery = url.searchParams.has("analysis") || + url.searchParams.has("analyze") || + ["/analysis", "/analyze"].includes(url.pathname); + + const badgeQuery = url.searchParams.has("badge") || ["/badge", "/badge/raster", "/badge-raster"].includes(url.pathname); + const polyfill = url.searchParams.has("polyfill"); + const prettyQuery = url.searchParams.has("pretty"); + const minifyQuery = url.searchParams.has("minify"); + const sourcemapQuery = url.searchParams.has("sourcemap"); + const tsxQuery = url.searchParams.has("tsx") || url.searchParams.has("jsx"); + const enableMetafile = analysisQuery || metafileQuery || Boolean(initialConfig?.analysis); + + const prettyResult = url.searchParams.get("pretty"); + const minifyResult = url.searchParams.get("minify"); + const minify = initialConfig?.esbuild?.minify ?? ( + minifyQuery + ? (minifyResult?.length === 0 ? true : convertQueryValue(minifyResult)) + : (prettyQuery ? (prettyResult?.length === 0 ? !prettyQuery : !convertQueryValue(prettyResult)) : null) ?? + initialConfig?.esbuild?.minify + ); + + const sourcemapResult = url.searchParams.get("sourcemap"); + const sourcemap = initialConfig?.esbuild?.sourcemap ?? ( + sourcemapQuery + ? convertQueryValue(sourcemapResult) + : initialConfig?.esbuild?.sourcemap + ); + + const formatQuery = url.searchParams.has("format"); + const format = initialConfig?.esbuild?.format || url.searchParams.get("format"); + + const earlyConfig = Object.assign(deepMerge( + deepMerge( + deepMerge( + Object.assign({}, BUILD_CONFIG), + { + polyfill, + compression: createCompressConfig(initialConfig.compression) + } + ), + initialConfig + ), + { + esbuild: Object.assign( + {}, + enableMetafile ? { metafile: enableMetafile } : {}, + minifyQuery || prettyQuery ? { minify } : {}, + sourcemapQuery ? { sourcemap } : {}, + formatQuery ? { format } : {} + ), + init: { + platform: "deno-wasm", + worker: false + } + } as Config + ), { + entryPoints: [`/index${tsxQuery || initialConfig.tsx ? ".tsx" : ".ts"}`] + }) as Config; + + const hasQuery = url.searchParams.has("q") || url.searchParams.has("query"); + const shareQuery = url.searchParams.get("share"); + const textQuery = url.searchParams.get("text"); + const query = (url.searchParams.get("q") || url.searchParams.get("query")) ?? "spring-easing"; + const mutationQueries = Boolean( + shareQuery || textQuery || minifyQuery || prettyQuery || polyfill || tsxQuery || + formatQuery || configQuery || badgeQuery || sourcemapQuery || analysisQuery || metafileQuery + ); + + const rootPkg = (earlyConfig["package.json"] ?? {}) as PackageJson; + const dependencies = Object.assign({}, rootPkg.devDependencies, rootPkg.peerDependencies, rootPkg.dependencies); + + const versionsList = await Promise.allSettled( + !hasQuery && (shareQuery || textQuery) + ? [] + : query + .split(",") + .map(value => [ + value.replace(/^\((\w+)\)/, ""), + /^\((\w+)\)/.exec(value)?.[1] ?? "export" + ] as const) + .filter(([pkgName]) => !/^https?\:\/\//.exec(pkgName)) + .map(async ([pkgName, imported]) => { + const { name = pkgName, version, path } = parsePackageName(pkgName, { ignoreError: true }); + return [ + name, + await resolveVersion(dependencies[name] ? `${name}@${dependencies[name]}` : pkgName) ?? version, + path, + imported + ] as const; + }) + ); + + const versions: string[] = []; + const modules: BundleModule[] = []; + + for (const version of versionsList) { + if (version.status === "fulfilled" && version.value) { + const [name, resolvedVersion, path, imported] = version.value; + versions.push(`${name}@${resolvedVersion}`); + modules.push([`${name}@${resolvedVersion}${path}`, imported ?? "export"]); + } + } + + const { init = {}, ...configWithoutInit } = earlyConfig; + const jsonKeyObject = Object.assign({}, configWithoutInit, { + init, + versions, + modules, + initialValue: initialValue.trim() + }) as BundleKeyObject; + + const jsonKey = `json/${JSON5.stringify(jsonKeyObject).trim()}`; + const badgeResult = url.searchParams.get("badge"); + const badgeStyle = url.searchParams.get("badge-style"); + const badgeRasterQuery = + url.searchParams.has("badge-raster") || + url.searchParams.has("png") || + ["/badge/raster", "/badge-raster"].includes(url.pathname); + + const badgeKey = `badge/${jsonKey}`; + const badgeID = JSON5.stringify(Object.assign({}, jsonKeyObject, { + badge: { + raster: badgeRasterQuery, + result: badgeResult, + style: badgeStyle + } + })).trim(); + + return { + bundleKey: await hashString(jsonKey), + query, + initialValue, + initialConfig, + earlyConfig, + versions, + modules, + jsonKeyObject, + jsonKey, + badgeKey, + badgeID, + exportAll, + mutationQueries, + shareQuery, + textQuery, + useTsxEntrypoint: Boolean(tsxQuery || initialConfig.tsx) + }; +} \ No newline at end of file diff --git a/packages/edge/types.ts b/packages/edge/types.ts new file mode 100644 index 0000000..64c2d5a --- /dev/null +++ b/packages/edge/types.ts @@ -0,0 +1,38 @@ +import type { BuildConfig } from "@bundle/core"; +import type { CompressConfig } from "@bundle/compress"; + +export type BundleModuleMode = "import" | "export" | (string & {}); +export type BundleModule = [string, BundleModuleMode]; + +export type Config = BuildConfig & { + compression?: CompressConfig; + analysis?: boolean | string; + tsx?: boolean; +}; + +export type BundleKeyObject = Omit & { + entryPoints?: Config["entryPoints"]; + init: NonNullable; + versions: string[]; + modules: BundleModule[]; + initialValue: string; +}; + +export type PreparedBundleRequest = { + bundleKey: string; + query: string; + initialValue: string; + initialConfig: Config; + earlyConfig: Config; + versions: string[]; + modules: BundleModule[]; + jsonKeyObject: BundleKeyObject; + jsonKey: string; + badgeKey: string; + badgeID: string; + exportAll: boolean; + mutationQueries: boolean; + shareQuery: string | null; + textQuery: string | null; + useTsxEntrypoint: boolean; +}; \ No newline at end of file diff --git a/cloudflare/README.md b/services/cloudflare/README.md similarity index 100% rename from cloudflare/README.md rename to services/cloudflare/README.md diff --git a/cloudflare/package.json b/services/cloudflare/package.json similarity index 100% rename from cloudflare/package.json rename to services/cloudflare/package.json diff --git a/cloudflare/src/durable-objects/bundle-coordinator.ts b/services/cloudflare/src/durable-objects/bundle-coordinator.ts similarity index 100% rename from cloudflare/src/durable-objects/bundle-coordinator.ts rename to services/cloudflare/src/durable-objects/bundle-coordinator.ts diff --git a/cloudflare/src/index.ts b/services/cloudflare/src/index.ts similarity index 100% rename from cloudflare/src/index.ts rename to services/cloudflare/src/index.ts diff --git a/cloudflare/src/result.ts b/services/cloudflare/src/result.ts similarity index 100% rename from cloudflare/src/result.ts rename to services/cloudflare/src/result.ts diff --git a/cloudflare/src/storage.ts b/services/cloudflare/src/storage.ts similarity index 100% rename from cloudflare/src/storage.ts rename to services/cloudflare/src/storage.ts diff --git a/cloudflare/src/types.ts b/services/cloudflare/src/types.ts similarity index 100% rename from cloudflare/src/types.ts rename to services/cloudflare/src/types.ts diff --git a/cloudflare/tsconfig.json b/services/cloudflare/tsconfig.json similarity index 100% rename from cloudflare/tsconfig.json rename to services/cloudflare/tsconfig.json diff --git a/cloudflare/worker-configuration.d.ts b/services/cloudflare/worker-configuration.d.ts similarity index 100% rename from cloudflare/worker-configuration.d.ts rename to services/cloudflare/worker-configuration.d.ts diff --git a/cloudflare/wrangler.jsonc b/services/cloudflare/wrangler.jsonc similarity index 100% rename from cloudflare/wrangler.jsonc rename to services/cloudflare/wrangler.jsonc diff --git a/edge/_shared/bundle/key.ts b/services/deno/_shared/bundle/key.ts similarity index 100% rename from edge/_shared/bundle/key.ts rename to services/deno/_shared/bundle/key.ts diff --git a/edge/_shared/bundle/legacy-response.ts b/services/deno/_shared/bundle/legacy-response.ts similarity index 99% rename from edge/_shared/bundle/legacy-response.ts rename to services/deno/_shared/bundle/legacy-response.ts index aa8c5d8..22debba 100644 --- a/edge/_shared/bundle/legacy-response.ts +++ b/services/deno/_shared/bundle/legacy-response.ts @@ -22,7 +22,7 @@ import { getBadgeKey, generateBadgeId } from '../bundle/key.ts' import { getCachedBadge, setCachedBadge } from '../cache/operations.ts' import { API_DOCS } from './schema.ts' -import styleText from '../../legacy/style.ts' +import styleText from './style.ts' export type LegacyResponseOptions = { url: URL diff --git a/edge/_shared/bundle/parse.ts b/services/deno/_shared/bundle/parse.ts similarity index 100% rename from edge/_shared/bundle/parse.ts rename to services/deno/_shared/bundle/parse.ts diff --git a/edge/_shared/bundle/request.ts b/services/deno/_shared/bundle/request.ts similarity index 97% rename from edge/_shared/bundle/request.ts rename to services/deno/_shared/bundle/request.ts index fd87732..84847b4 100644 --- a/edge/_shared/bundle/request.ts +++ b/services/deno/_shared/bundle/request.ts @@ -14,7 +14,7 @@ import type { BundleResult, BundleConfig, ModuleSpec, CacheMode } from './types. import { parseInputToConfig } from './parse.ts' import { executeBundle, executeBundleWithOutput } from './service.ts' -import { generateCacheKey, generateEntryPointHash } from '../cache/keys.ts' +import { generateCacheKey, generateEntryPointHash } from './key.ts' import { getCachedResult, setCachedResult } from '../cache/operations.ts' export type BundleResolution = { diff --git a/edge/_shared/bundle/schema.ts b/services/deno/_shared/bundle/schema.ts similarity index 100% rename from edge/_shared/bundle/schema.ts rename to services/deno/_shared/bundle/schema.ts diff --git a/edge/_shared/bundle/service.ts b/services/deno/_shared/bundle/service.ts similarity index 100% rename from edge/_shared/bundle/service.ts rename to services/deno/_shared/bundle/service.ts diff --git a/edge/legacy/style.ts b/services/deno/_shared/bundle/style.ts similarity index 100% rename from edge/legacy/style.ts rename to services/deno/_shared/bundle/style.ts diff --git a/edge/_shared/bundle/types.ts b/services/deno/_shared/bundle/types.ts similarity index 100% rename from edge/_shared/bundle/types.ts rename to services/deno/_shared/bundle/types.ts diff --git a/edge/_shared/bundle/wasm.ts b/services/deno/_shared/bundle/wasm.ts similarity index 100% rename from edge/_shared/bundle/wasm.ts rename to services/deno/_shared/bundle/wasm.ts diff --git a/edge/_shared/cache/client.ts b/services/deno/_shared/cache/client.ts similarity index 100% rename from edge/_shared/cache/client.ts rename to services/deno/_shared/cache/client.ts diff --git a/edge/_shared/cache/operations.ts b/services/deno/_shared/cache/operations.ts similarity index 100% rename from edge/_shared/cache/operations.ts rename to services/deno/_shared/cache/operations.ts diff --git a/edge/_shared/external/gist.ts b/services/deno/_shared/external/gist.ts similarity index 100% rename from edge/_shared/external/gist.ts rename to services/deno/_shared/external/gist.ts diff --git a/edge/_shared/external/shields.ts b/services/deno/_shared/external/shields.ts similarity index 100% rename from edge/_shared/external/shields.ts rename to services/deno/_shared/external/shields.ts diff --git a/edge/_shared/middleware/cache-control.ts b/services/deno/_shared/middleware/cache-control.ts similarity index 96% rename from edge/_shared/middleware/cache-control.ts rename to services/deno/_shared/middleware/cache-control.ts index 417c4f0..e1b8ba8 100644 --- a/edge/_shared/middleware/cache-control.ts +++ b/services/deno/_shared/middleware/cache-control.ts @@ -13,7 +13,7 @@ import type { CacheMode } from '../bundle/types.ts' import type { MiddlewareHandler } from 'hono' -export type CacheControlVariables = { +export interface CacheControlVariables { cacheMode?: CacheMode } diff --git a/edge/_shared/middleware/rate-limit.ts b/services/deno/_shared/middleware/rate-limit.ts similarity index 95% rename from edge/_shared/middleware/rate-limit.ts rename to services/deno/_shared/middleware/rate-limit.ts index a2972c0..66fd60b 100644 --- a/edge/_shared/middleware/rate-limit.ts +++ b/services/deno/_shared/middleware/rate-limit.ts @@ -8,13 +8,13 @@ * - Graceful degradation when Redis unavailable */ -import type { EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' import { createMiddleware } from 'hono/factory' import { rateLimiter } from 'hono-rate-limiter' import { RedisStore } from '@hono-rate-limiter/redis' import { getRedisClient } from '#shared/cache/client.ts' -import { rateLimitExceeded } from '#shared/response/mod.ts' +import { rateLimitExceeded } from '@utils/response' export interface RateLimitOptions { windowMs: number diff --git a/edge/_shared/middleware/redis.ts b/services/deno/_shared/middleware/redis.ts similarity index 100% rename from edge/_shared/middleware/redis.ts rename to services/deno/_shared/middleware/redis.ts diff --git a/edge/_shared/utils/parse-query.test.ts b/services/deno/_shared/utils/parse-query.test.ts similarity index 100% rename from edge/_shared/utils/parse-query.test.ts rename to services/deno/_shared/utils/parse-query.test.ts diff --git a/edge/_shared/utils/parse-query.ts b/services/deno/_shared/utils/parse-query.ts similarity index 100% rename from edge/_shared/utils/parse-query.ts rename to services/deno/_shared/utils/parse-query.ts diff --git a/services/deno/deno.jsonc b/services/deno/deno.jsonc new file mode 100644 index 0000000..8fa83bc --- /dev/null +++ b/services/deno/deno.jsonc @@ -0,0 +1,41 @@ +{ + "name": "@services/deno", + "version": "0.0.0", + "exports": {}, + "imports": { + "@octokit/core": "npm:@octokit/core", + "@octokit/rest": "npm:@octokit/rest", + "@octokit/plugin-throttling": "npm:@octokit/plugin-throttling", + "@upstash/redis": "npm:@upstash/redis", + + "@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", + + "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", + + "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", + + "#shared/": "./_shared/" + }, + "tasks": { + "dev": "deno serve -A --watch mod.ts" + }, + "compilerOptions": { + "lib": ["deno.window"], + "types": ["npm:@types/node"], + "strict": true + }, + "fmt": { + "semiColons": false, + "singleQuote": true + } +} \ No newline at end of file diff --git a/edge/endpoints/badge/raster/definition.ts b/services/deno/endpoints/badge/raster/definition.ts similarity index 86% rename from edge/endpoints/badge/raster/definition.ts rename to services/deno/endpoints/badge/raster/definition.ts index f0dbbe4..b040aee 100644 --- a/edge/endpoints/badge/raster/definition.ts +++ b/services/deno/endpoints/badge/raster/definition.ts @@ -4,7 +4,7 @@ * GET /v1/badge/raster */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' import { BundleQuerySchema } from '#shared/bundle/schema.ts' diff --git a/edge/endpoints/badge/raster/handler.ts b/services/deno/endpoints/badge/raster/handler.ts similarity index 89% rename from edge/endpoints/badge/raster/handler.ts rename to services/deno/endpoints/badge/raster/handler.ts index 6643025..310ca42 100644 --- a/edge/endpoints/badge/raster/handler.ts +++ b/services/deno/endpoints/badge/raster/handler.ts @@ -4,14 +4,14 @@ * GET /v1/badge/raster */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' import type { CacheControlVariables } from '#shared/middleware/cache-control.ts' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' import { rateLimitMiddleware } from '#shared/middleware/rate-limit.ts' import { cacheControlMiddleware } from '#shared/middleware/cache-control.ts' -import { badRequest } from '#shared/response/mod.ts' +import { badRequest } from '@utils/response' import { resolveBundleRequest } from '#shared/bundle/request.ts' import { generateLegacyResponse } from '#shared/bundle/legacy-response.ts' diff --git a/edge/endpoints/badge/svg/definition.ts b/services/deno/endpoints/badge/svg/definition.ts similarity index 85% rename from edge/endpoints/badge/svg/definition.ts rename to services/deno/endpoints/badge/svg/definition.ts index ca5a105..9c697e9 100644 --- a/edge/endpoints/badge/svg/definition.ts +++ b/services/deno/endpoints/badge/svg/definition.ts @@ -4,7 +4,7 @@ * GET /v1/badge */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' import { BundleQuerySchema } from '#shared/bundle/schema.ts' diff --git a/edge/endpoints/badge/svg/handler.ts b/services/deno/endpoints/badge/svg/handler.ts similarity index 92% rename from edge/endpoints/badge/svg/handler.ts rename to services/deno/endpoints/badge/svg/handler.ts index afb0518..2db8ab0 100644 --- a/edge/endpoints/badge/svg/handler.ts +++ b/services/deno/endpoints/badge/svg/handler.ts @@ -11,14 +11,14 @@ * - SVG response with caching headers */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' import type { CacheControlVariables } from '#shared/middleware/cache-control.ts' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' import { rateLimitMiddleware } from '#shared/middleware/rate-limit.ts' import { cacheControlMiddleware } from '#shared/middleware/cache-control.ts' -import { badRequest } from '#shared/response/mod.ts' +import { badRequest } from '@utils/response' import { resolveBundleRequest } from '#shared/bundle/request.ts' import { generateLegacyResponse } from '#shared/bundle/legacy-response.ts' diff --git a/edge/endpoints/bundle/analysis/definition.ts b/services/deno/endpoints/bundle/analysis/definition.ts similarity index 86% rename from edge/endpoints/bundle/analysis/definition.ts rename to services/deno/endpoints/bundle/analysis/definition.ts index 1b861c8..7f124f3 100644 --- a/edge/endpoints/bundle/analysis/definition.ts +++ b/services/deno/endpoints/bundle/analysis/definition.ts @@ -4,7 +4,7 @@ * GET /v1/bundle/analysis */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' import { BundleQuerySchema } from '#shared/bundle/schema.ts' diff --git a/edge/endpoints/bundle/analysis/handler.ts b/services/deno/endpoints/bundle/analysis/handler.ts similarity index 89% rename from edge/endpoints/bundle/analysis/handler.ts rename to services/deno/endpoints/bundle/analysis/handler.ts index 5a458c1..9ee37ae 100644 --- a/edge/endpoints/bundle/analysis/handler.ts +++ b/services/deno/endpoints/bundle/analysis/handler.ts @@ -4,14 +4,14 @@ * GET /v1/bundle/analysis */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' import type { CacheControlVariables } from '#shared/middleware/cache-control.ts' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' import { rateLimitMiddleware } from '#shared/middleware/rate-limit.ts' import { cacheControlMiddleware } from '#shared/middleware/cache-control.ts' -import { badRequest } from '#shared/response/mod.ts' +import { badRequest } from '@utils/response' import { resolveBundleRequest } from '#shared/bundle/request.ts' import { generateLegacyResponse } from '#shared/bundle/legacy-response.ts' diff --git a/edge/endpoints/bundle/file/definition.ts b/services/deno/endpoints/bundle/file/definition.ts similarity index 86% rename from edge/endpoints/bundle/file/definition.ts rename to services/deno/endpoints/bundle/file/definition.ts index e23bc79..70114ad 100644 --- a/edge/endpoints/bundle/file/definition.ts +++ b/services/deno/endpoints/bundle/file/definition.ts @@ -4,7 +4,7 @@ * GET /v1/bundle/file */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' import { BundleQuerySchema } from '#shared/bundle/schema.ts' diff --git a/edge/endpoints/bundle/file/handler.ts b/services/deno/endpoints/bundle/file/handler.ts similarity index 90% rename from edge/endpoints/bundle/file/handler.ts rename to services/deno/endpoints/bundle/file/handler.ts index dd4560c..4fc3b26 100644 --- a/edge/endpoints/bundle/file/handler.ts +++ b/services/deno/endpoints/bundle/file/handler.ts @@ -4,14 +4,14 @@ * GET /v1/bundle/file */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' import type { CacheControlVariables } from '#shared/middleware/cache-control.ts' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' import { rateLimitMiddleware } from '#shared/middleware/rate-limit.ts' import { cacheControlMiddleware } from '#shared/middleware/cache-control.ts' -import { badRequest } from '#shared/response/mod.ts' +import { badRequest } from '@utils/response' import { resolveBundleRequest } from '#shared/bundle/request.ts' import { generateLegacyResponse } from '#shared/bundle/legacy-response.ts' diff --git a/edge/endpoints/bundle/metafile/definition.ts b/services/deno/endpoints/bundle/metafile/definition.ts similarity index 86% rename from edge/endpoints/bundle/metafile/definition.ts rename to services/deno/endpoints/bundle/metafile/definition.ts index 20ec898..b42c95e 100644 --- a/edge/endpoints/bundle/metafile/definition.ts +++ b/services/deno/endpoints/bundle/metafile/definition.ts @@ -4,7 +4,7 @@ * GET /v1/bundle/metafile */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' import { BundleQuerySchema } from '#shared/bundle/schema.ts' diff --git a/edge/endpoints/bundle/metafile/handler.ts b/services/deno/endpoints/bundle/metafile/handler.ts similarity index 89% rename from edge/endpoints/bundle/metafile/handler.ts rename to services/deno/endpoints/bundle/metafile/handler.ts index d04a8f5..154a98d 100644 --- a/edge/endpoints/bundle/metafile/handler.ts +++ b/services/deno/endpoints/bundle/metafile/handler.ts @@ -4,14 +4,14 @@ * GET /v1/bundle/metafile */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' import type { CacheControlVariables } from '#shared/middleware/cache-control.ts' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' import { rateLimitMiddleware } from '#shared/middleware/rate-limit.ts' import { cacheControlMiddleware } from '#shared/middleware/cache-control.ts' -import { badRequest } from '#shared/response/mod.ts' +import { badRequest } from '@utils/response' import { resolveBundleRequest } from '#shared/bundle/request.ts' import { generateLegacyResponse } from '#shared/bundle/legacy-response.ts' diff --git a/edge/endpoints/bundle/raw/definition.ts b/services/deno/endpoints/bundle/raw/definition.ts similarity index 86% rename from edge/endpoints/bundle/raw/definition.ts rename to services/deno/endpoints/bundle/raw/definition.ts index c6871f8..844cad9 100644 --- a/edge/endpoints/bundle/raw/definition.ts +++ b/services/deno/endpoints/bundle/raw/definition.ts @@ -4,7 +4,7 @@ * GET /v1/bundle/raw */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' import { BundleQuerySchema } from '#shared/bundle/schema.ts' diff --git a/edge/endpoints/bundle/raw/handler.ts b/services/deno/endpoints/bundle/raw/handler.ts similarity index 89% rename from edge/endpoints/bundle/raw/handler.ts rename to services/deno/endpoints/bundle/raw/handler.ts index a7192ee..f6cc678 100644 --- a/edge/endpoints/bundle/raw/handler.ts +++ b/services/deno/endpoints/bundle/raw/handler.ts @@ -4,14 +4,14 @@ * GET /v1/bundle/raw */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' import type { CacheControlVariables } from '#shared/middleware/cache-control.ts' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' import { rateLimitMiddleware } from '#shared/middleware/rate-limit.ts' import { cacheControlMiddleware } from '#shared/middleware/cache-control.ts' -import { badRequest } from '#shared/response/mod.ts' +import { badRequest } from '@utils/response' import { resolveBundleRequest } from '#shared/bundle/request.ts' import { generateLegacyResponse } from '#shared/bundle/legacy-response.ts' diff --git a/edge/endpoints/bundle/run/definition.ts b/services/deno/endpoints/bundle/run/definition.ts similarity index 88% rename from edge/endpoints/bundle/run/definition.ts rename to services/deno/endpoints/bundle/run/definition.ts index ceb2dd9..98bdd73 100644 --- a/edge/endpoints/bundle/run/definition.ts +++ b/services/deno/endpoints/bundle/run/definition.ts @@ -10,11 +10,11 @@ * - Supports caching */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' -import { BaseJsonSchema } from '#shared/server/schemas.ts' -import { makeSuccessResponseSchema } from '#shared/response/schemas.ts' -import { BundleResultSchema } from '#shared/schemas/bundle-result.ts' +import { BaseJsonSchema } from '@utils/endpoint/schemas' +import { makeSuccessResponseSchema } from '@utils/response/schemas' +import { BundleResultSchema } from '#shared/bundle/schema.ts' import { BundleQuerySchema } from '#shared/bundle/schema.ts' // ============================================================================ @@ -36,7 +36,7 @@ export const JsonSchema = BaseJsonSchema.pipe( text: z.string().optional(), share: z.string().optional(), treeshake: z.string().optional(), - config: z.union([z.record(z.unknown()), z.string()]).optional(), + config: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(), minify: z.boolean().optional(), pretty: z.boolean().optional(), sourcemap: z.union([z.boolean(), z.literal('inline'), z.literal('external'), z.literal('both')]).optional(), diff --git a/edge/endpoints/bundle/run/handler.ts b/services/deno/endpoints/bundle/run/handler.ts similarity index 84% rename from edge/endpoints/bundle/run/handler.ts rename to services/deno/endpoints/bundle/run/handler.ts index bf563b7..15ded01 100644 --- a/edge/endpoints/bundle/run/handler.ts +++ b/services/deno/endpoints/bundle/run/handler.ts @@ -12,15 +12,15 @@ * 5. Returns formatted response */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' -import type { CorrelationVariables } from '#shared/middleware/correlation.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' +import type { CorrelationVariables } from '@utils/middleware/correlation' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' import { rateLimitMiddleware } from '#shared/middleware/rate-limit.ts' import { cacheControlMiddleware, type CacheControlVariables } from '#shared/middleware/cache-control.ts' -import { ok, internalServerError, badRequest, validationFailed } from '#shared/response/mod.ts' -import { getLogger } from '#shared/middleware/correlation.ts' +import { ok, internalServerError, badRequest, validationFailed } from '@utils/response' +import { getLogger } from '@utils/middleware/correlation' import { resolveBundleRequest } from '#shared/bundle/request.ts' import { generateLegacyResponse } from '#shared/bundle/legacy-response.ts' @@ -58,8 +58,8 @@ export const Handler: EndpointHandler = async functio const logger = getLogger(c) const startTime = performance.now() const method = c.req.method - const isLegacyPath = !c.req.path.includes('/v1/') - + // const isLegacyPath = !c.req.path.includes('/v1/') + try { // Get input from query or body let input = method === 'POST' @@ -94,23 +94,23 @@ export const Handler: EndpointHandler = async functio input, baseUrl: c.req.url, cacheMode, - includeOutputText: isLegacyPath ? 'auto' : false, + includeOutputText: false, // isLegacyPath ? 'auto' : }) if (!resolution.inputCode && resolution.modules.length === 0) { return c.json(...badRequest(c.req.path, 'No modules or code provided')) } - if (isLegacyPath) { - return await generateLegacyResponse({ - url: resolution.url, - result: resolution.result, - outputText: resolution.outputText, - cached: resolution.cached, - durationMs: resolution.durationMs, - cacheKey: resolution.cacheKey, - }) - } + // if (isLegacyPath) { + // return await generateLegacyResponse({ + // url: resolution.url, + // result: resolution.result, + // outputText: resolution.outputText, + // cached: resolution.cached, + // durationMs: resolution.durationMs, + // cacheKey: resolution.cacheKey, + // }) + // } const duration = performance.now() - startTime c.header('X-Bundle-Duration-ms', duration.toFixed(2)) diff --git a/edge/endpoints/bundle/warnings/definition.ts b/services/deno/endpoints/bundle/warnings/definition.ts similarity index 86% rename from edge/endpoints/bundle/warnings/definition.ts rename to services/deno/endpoints/bundle/warnings/definition.ts index 1a81873..125f54c 100644 --- a/edge/endpoints/bundle/warnings/definition.ts +++ b/services/deno/endpoints/bundle/warnings/definition.ts @@ -4,7 +4,7 @@ * GET /v1/bundle/warnings */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' import { BundleQuerySchema } from '#shared/bundle/schema.ts' diff --git a/edge/endpoints/bundle/warnings/handler.ts b/services/deno/endpoints/bundle/warnings/handler.ts similarity index 89% rename from edge/endpoints/bundle/warnings/handler.ts rename to services/deno/endpoints/bundle/warnings/handler.ts index 3e7b6b8..a75213a 100644 --- a/edge/endpoints/bundle/warnings/handler.ts +++ b/services/deno/endpoints/bundle/warnings/handler.ts @@ -4,14 +4,14 @@ * GET /v1/bundle/warnings */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' import type { CacheControlVariables } from '#shared/middleware/cache-control.ts' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' import { rateLimitMiddleware } from '#shared/middleware/rate-limit.ts' import { cacheControlMiddleware } from '#shared/middleware/cache-control.ts' -import { badRequest } from '#shared/response/mod.ts' +import { badRequest } from '@utils/response' import { resolveBundleRequest } from '#shared/bundle/request.ts' import { generateLegacyResponse } from '#shared/bundle/legacy-response.ts' diff --git a/edge/endpoints/cache/clear/definition.ts b/services/deno/endpoints/cache/clear/definition.ts similarity index 73% rename from edge/endpoints/cache/clear/definition.ts rename to services/deno/endpoints/cache/clear/definition.ts index aa61794..7fb1653 100644 --- a/edge/endpoints/cache/clear/definition.ts +++ b/services/deno/endpoints/cache/clear/definition.ts @@ -4,9 +4,9 @@ * GET /v1/cache/clear */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' +import { BaseQuerySchema } from '@utils/endpoint/schemas' import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' export const QuerySchema = BaseQuerySchema diff --git a/edge/endpoints/cache/clear/handler.ts b/services/deno/endpoints/cache/clear/handler.ts similarity index 87% rename from edge/endpoints/cache/clear/handler.ts rename to services/deno/endpoints/cache/clear/handler.ts index d4cf16c..acedfef 100644 --- a/edge/endpoints/cache/clear/handler.ts +++ b/services/deno/endpoints/cache/clear/handler.ts @@ -4,11 +4,11 @@ * GET /v1/cache/clear */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' -import { ok, internalServerError } from '#shared/response/mod.ts' +import { ok, internalServerError } from '@utils/response' import { flushCacheAsync } from '#shared/cache/operations.ts' import Definition from './definition.ts' diff --git a/edge/endpoints/cache/purge/definition.ts b/services/deno/endpoints/cache/purge/definition.ts similarity index 86% rename from edge/endpoints/cache/purge/definition.ts rename to services/deno/endpoints/cache/purge/definition.ts index 21f2543..5b028aa 100644 --- a/edge/endpoints/cache/purge/definition.ts +++ b/services/deno/endpoints/cache/purge/definition.ts @@ -4,7 +4,7 @@ * GET/POST /v1/cache/purge */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' import { BundleQuerySchema } from '#shared/bundle/schema.ts' diff --git a/edge/endpoints/cache/purge/handler.ts b/services/deno/endpoints/cache/purge/handler.ts similarity index 90% rename from edge/endpoints/cache/purge/handler.ts rename to services/deno/endpoints/cache/purge/handler.ts index 93cd561..7c8b315 100644 --- a/edge/endpoints/cache/purge/handler.ts +++ b/services/deno/endpoints/cache/purge/handler.ts @@ -4,14 +4,14 @@ * GET/POST /v1/cache/purge */ -import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '#shared/server/types.ts' +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' -import { createValidator } from '#shared/middleware/validation.ts' +import { createValidator } from '@utils/middleware/validation' -import { ok, internalServerError } from '#shared/response/mod.ts' +import { ok, internalServerError } from '@utils/response' import { parseInputToConfig } from '#shared/bundle/parse.ts' -import { generateCacheKey, getBadgeKey, getPackageResultKey } from '#shared/cache/keys.ts' +import { generateCacheKey, getBadgeKey, getPackageResultKey } from '#shared/bundle/key.ts' import { deleteCachedBadges, deleteCachedResult } from '#shared/cache/operations.ts' import Definition from './definition.ts' diff --git a/edge/endpoints/health/definition.ts b/services/deno/endpoints/static/definition.ts similarity index 54% rename from edge/endpoints/health/definition.ts rename to services/deno/endpoints/static/definition.ts index e1aceff..b82b46a 100644 --- a/edge/endpoints/health/definition.ts +++ b/services/deno/endpoints/static/definition.ts @@ -1,18 +1,18 @@ /** - * Health Endpoint Definition + * Public Static Asset Endpoint Definition * - * GET /health + * GET /* */ -import type { EndpointDefinition } from '#shared/server/types.ts' +import type { EndpointDefinition } from '@utils/endpoint/types' import { z } from 'zod' -import { BaseQuerySchema } from '#shared/server/schemas.ts' +import { BaseQuerySchema } from '@utils/endpoint/schemas' export const QuerySchema = BaseQuerySchema export default { - Name: 'health', - Route: '/health', + Name: 'static', + Route: '/*', Methods: ['GET'] as const, Input: QuerySchema, Output: z.any(), diff --git a/services/deno/endpoints/static/handler.ts b/services/deno/endpoints/static/handler.ts new file mode 100644 index 0000000..c495a9e --- /dev/null +++ b/services/deno/endpoints/static/handler.ts @@ -0,0 +1,23 @@ +/** + * Public Static Asset Handler + * + * Serves files from the repo's `public/` directory. + */ + +import type { EndpointHandler, EndpointMiddlewareHandler, FunctionAppEnv } from '@utils/endpoint/types' +import { fileURLToPath } from 'node:url' +import { serveStatic } from 'hono/deno' + +import type Definition from './definition.ts' + +export type AppEnv = FunctionAppEnv + +const PUBLIC_ROOT = fileURLToPath(new URL("../../../../public/", import.meta.url)) +const staticHandler = serveStatic({ root: PUBLIC_ROOT }) + +export const Middleware: EndpointMiddlewareHandler[] = [] +export const Handler: EndpointHandler = (c, next) => { + return staticHandler(c, next) +} + +export default Handler diff --git a/services/deno/endpoints/static/handler_test.ts b/services/deno/endpoints/static/handler_test.ts new file mode 100644 index 0000000..02b2f5f --- /dev/null +++ b/services/deno/endpoints/static/handler_test.ts @@ -0,0 +1,33 @@ +import { expect } from '@std/expect' +import { describe, test } from '@std/testing/bdd' +import { Hono } from 'hono' + +import Definition from './definition.ts' +import Handler from './handler.ts' + +function createApp() { + const app = new Hono() + app.get(Definition.Route, Handler) + return app +} + +describe('static handler', () => { + test('serves the OpenAPI document from the public directory', async () => { + const app = createApp() + + const response = await app.request('http://localhost/.well-known/openapi.yaml') + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('application/') + expect(await response.text()).toContain('openapi: 3.0.0') + }) + + test('returns 404 for the root path when no public index file exists', async () => { + const app = createApp() + + const response = await app.request('http://localhost/') + + expect(response.status).toBe(404) + expect(await response.text()).toContain('404 Not Found') + }) +}) \ No newline at end of file diff --git a/services/deno/index.ts b/services/deno/index.ts new file mode 100644 index 0000000..b472157 --- /dev/null +++ b/services/deno/index.ts @@ -0,0 +1,51 @@ +// index.ts +/** + * Bundle Service: Endpoint Definitions Registry + * + * All endpoint contracts in one place for: + * - Type-safe registration + * - Client type generation + * - OpenAPI schema derivation + */ + +// Bundle endpoints +import BundleRunDef from './endpoints/bundle/run/definition.ts' +import BundleFileDef from './endpoints/bundle/file/definition.ts' +import BundleMetafileDef from './endpoints/bundle/metafile/definition.ts' +import BundleAnalysisDef from './endpoints/bundle/analysis/definition.ts' +import BundleWarningsDef from './endpoints/bundle/warnings/definition.ts' +import BundleRawDef from './endpoints/bundle/raw/definition.ts' + +// Badge endpoints +import BadgeSvgDef from './endpoints/badge/svg/definition.ts' +import BadgeRasterDef from './endpoints/badge/raster/definition.ts' + +// Cache admin endpoints +import CachePurgeDef from './endpoints/cache/purge/definition.ts' +import CacheClearDef from './endpoints/cache/clear/definition.ts' + +// Static endpoints +import StaticDef from './endpoints/static/definition.ts' + +export const EndpointDefinitions = { + // Bundle + BundleRun: BundleRunDef, + BundleFile: BundleFileDef, + BundleMetafile: BundleMetafileDef, + BundleAnalysis: BundleAnalysisDef, + BundleWarnings: BundleWarningsDef, + BundleRaw: BundleRawDef, + + // Badge + BadgeSvg: BadgeSvgDef, + BadgeRaster: BadgeRasterDef, + + // Cache admin + CachePurge: CachePurgeDef, + CacheClear: CacheClearDef, + + // Static + Static: StaticDef, +} as const + +export default EndpointDefinitions \ No newline at end of file diff --git a/edge/endpoints/legacy/routes.ts b/services/deno/legacy.ts similarity index 97% rename from edge/endpoints/legacy/routes.ts rename to services/deno/legacy.ts index bef7aac..5b2ce01 100644 --- a/edge/endpoints/legacy/routes.ts +++ b/services/deno/legacy.ts @@ -8,7 +8,7 @@ import type { Hono, Env } from 'hono' import type { BlankEnv } from 'hono/types' -import type { EndpointHandlerModule } from '#shared/server/types.ts' +import type { EndpointHandlerModule } from '@utils/endpoint/types' export function registerLegacyRoutes( app: Hono, diff --git a/edge/index.ts b/services/deno/mod.ts similarity index 67% rename from edge/index.ts rename to services/deno/mod.ts index 61040ad..94e7a03 100644 --- a/edge/index.ts +++ b/services/deno/mod.ts @@ -1,4 +1,4 @@ -// index.ts +// mod.ts /** * Bundle Service: Main Entry Point * @@ -9,8 +9,8 @@ * - Legacy path compatibility */ -import type { EndpointHandlerModule } from '#shared/server/types.ts' -import { createApp } from '#shared/server/create-app.ts' +import type { EndpointHandlerModule } from '@utils/endpoint/types' +import { createServer } from '@utils/server' import { showRoutes } from 'hono/dev' // Import endpoint handlers @@ -27,19 +27,10 @@ import * as BadgeRasterHandler from './endpoints/badge/raster/handler.ts' import * as CachePurgeHandler from './endpoints/cache/purge/handler.ts' import * as CacheClearHandler from './endpoints/cache/clear/handler.ts' -import * as OpenApiHandler from './endpoints/static/openapi/handler.ts' -import * as PluginHandler from './endpoints/static/plugin/handler.ts' -import * as RobotsHandler from './endpoints/static/robots/handler.ts' -import * as LlmHandler from './endpoints/static/llms/handler.ts' -import * as ServiceWorkerHandler from './endpoints/static/sw/handler.ts' -import * as FaviconHandler from './endpoints/static/favicon/handler.ts' -import * as AppleTouchIconHandler from './endpoints/static/apple-touch-icon/handler.ts' -import * as AppleTouchIconPrecomposedHandler from './endpoints/static/apple-touch-icon-precomposed/handler.ts' +import * as StaticHandler from './endpoints/static/handler.ts' -import * as HealthHandler from './endpoints/health/handler.ts' - -import { EndpointDefinitions } from './endpoints/bundle/mod.ts' -import { registerLegacyRoutes } from './endpoints/legacy/routes.ts' +import { EndpointDefinitions } from './index.ts' +import { registerLegacyRoutes } from './legacy.ts' // ============================================================================ // Handler Registry @@ -63,28 +54,18 @@ export const EndpointHandlers = { [EndpointDefinitions.CacheClear.Name]: CacheClearHandler, // Static endpoints - [EndpointDefinitions.OpenApi.Name]: OpenApiHandler, - [EndpointDefinitions.Plugin.Name]: PluginHandler, - [EndpointDefinitions.Robots.Name]: RobotsHandler, - [EndpointDefinitions.Llm.Name]: LlmHandler, - [EndpointDefinitions.ServiceWorker.Name]: ServiceWorkerHandler, - [EndpointDefinitions.Favicon.Name]: FaviconHandler, - [EndpointDefinitions.AppleTouchIcon.Name]: AppleTouchIconHandler, - [EndpointDefinitions.AppleTouchIconPrecomposed.Name]: AppleTouchIconPrecomposedHandler, - - // Health - [EndpointDefinitions.Health.Name]: HealthHandler, + [EndpointDefinitions.Static.Name]: StaticHandler, } as const satisfies Record // ============================================================================ // Create App // ============================================================================ -const app = createApp('bundle', { +const app = createServer('', { serviceName: 'bundle-service', cors: { origin: '*', - allowMethods: ['GET', 'POST', 'OPTIONS'], + allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], exposeHeaders: ['X-Request-ID', 'X-Cache', 'X-Bundle-Duration-ms'], }, @@ -123,7 +104,4 @@ showRoutes(app, { verbose: true }) // ============================================================================ export { app } -export default app - -// Start server when run directly -Deno.serve(app.fetch) \ No newline at end of file +export default app \ No newline at end of file diff --git a/edge/package.json b/services/deno/package.json similarity index 83% rename from edge/package.json rename to services/deno/package.json index ad9b334..0b15f1d 100644 --- a/edge/package.json +++ b/services/deno/package.json @@ -1,5 +1,5 @@ { - "name": "@bundle/edge", + "name": "@services/deno", "version": "0.0.0", "private": true, "type": "module", @@ -15,12 +15,13 @@ "@bundle/compress": "workspace:*", "@bundle/core": "workspace:*", "@bundle/utils": "workspace:*", + "@utils/server": "workspace:*", + "@octokit/core": "latest", "@octokit/plugin-throttling": "^11.0.3", "@octokit/rest": "^22.0.1", "@std/encoding": "npm:@jsr/std__encoding@^1", "@std/path": "npm:@jsr/std__path@^1", - "@upstash/redis": "^1.37.0", - "json5": "^2.2.3" + "@upstash/redis": "^1.37.0" }, "scripts": { "dev": "deno serve -A --watch mod.ts" diff --git a/edge/legacy/README.md b/services/legacy/README.md similarity index 100% rename from edge/legacy/README.md rename to services/legacy/README.md diff --git a/edge/legacy/bundle.ts b/services/legacy/bundle.ts similarity index 100% rename from edge/legacy/bundle.ts rename to services/legacy/bundle.ts diff --git a/edge/legacy/generate-result.ts b/services/legacy/generate-result.ts similarity index 100% rename from edge/legacy/generate-result.ts rename to services/legacy/generate-result.ts diff --git a/services/legacy/gist.ts b/services/legacy/gist.ts new file mode 100644 index 0000000..8f9bd14 --- /dev/null +++ b/services/legacy/gist.ts @@ -0,0 +1,152 @@ +import type { ESBUILD } from "@bundle/core/types"; + +// @deno-types="npm:@octokit/rest" +import { Octokit } from "@octokit/rest"; + +// @deno-types="npm:@octokit/plugin-throttling" +import { throttling } from "@octokit/plugin-throttling"; + +import { LruCache } from "@bundle/utils/lru"; +import { encodeBase64 } from "@bundle/utils/encoding"; +import { extname } from "@bundle/utils/path"; + +export const GIST_CACHE = new LruCache(10); +export const CustomOctokit = Octokit.plugin(throttling); + +export const octokit = new CustomOctokit({ + auth: Deno.env.get('GITHUB_AUTH_TOKEN'), + throttle: { + timeout: 1000 * 5, + onRateLimit: (retryAfter: any, options: { method: any; url: any; }, octokit: { log: { warn: (arg0: string) => void; info: (arg0: string) => void; }; }, retryCount: number) => { + console.log({ + GIST: options + }) + octokit.log.warn( + `Request quota exhausted for request ${options.method} ${options.url}` + ); + + if (retryCount < 1) { + // only retries once + octokit.log.info(`Retrying after ${retryAfter} seconds!`); + return true; + } + }, + onSecondaryRateLimit: (retryAfter: any, options: { method: any; url: any; }, octokit: { log: { warn: (arg0: string) => void; }; }) => { + // does not retry, only logs a warning + octokit.log.warn( + `SecondaryRateLimit detected for request ${options.method} ${options.url}` + ); + }, + // onAbuseLimit: (retryAfter: any, options: { method: any; url: any; }) => { + // // does not retry, only logs a warning + // octokit.log.warn( + // `Abuse detected for request ${options.method} ${options.url}` + // ); + // }, + }, +}) + +export const BUNDLE_FILE_PATH = "index.js"; +export async function setFile(url: string, files: ESBUILD.OutputFile[]) { + const newUrl = new URL(url); + newUrl.hostname = "deno.bundlejs.com"; + + try { + const filesObj = Object.fromEntries( + files.map(x => { + const path = x.path.replace(/^\.|\//g, "").replace(/[^\w-_.]/g, ""); + return [ + path, + { + content: ( + /\.(wasm|png|jpg|jpeg)$/.exec(extname(path)) ? + encodeBase64(x.contents) : + x.text + ) || "[bundlejs] Empty file..." + } + ] + }) + ); + + const result = ( + // 'POST /gists', + await octokit.rest.gists.create({ + description: `Result of ${newUrl.href}`.slice(0, 255), + 'public': false, + files: { + ...filesObj, + 'README.md': { + content: `Hey 👋, this is a gist which stores the final bundle results of the bundlejs api, learn more on the website https://bundlejs.com. This is the result of ${newUrl.href}.` + } + }, + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + }, + }) + ).data; + + const fileId = result.id; + const fileUrl = result.url; + const fileHTMLUrl = result.html_url; + if (fileId) GIST_CACHE.set(fileId, filesObj[BUNDLE_FILE_PATH].content); + return { + fileId, + fileUrl, + fileHTMLUrl + }; + } catch (e) { + console.warn('gist set: ', e); + } +} + +export function listFiles() { + // 'GET /gists' + const list = octokit.paginate.iterator(octokit.rest.gists.list, { + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + return list; +} + +export async function getFile(id: string) { + const rawFile = GIST_CACHE.get(id); + if (rawFile) return rawFile; + + try { + //'GET /gists/{gist_id}' + const req = await octokit.rest.gists.get({ + gist_id: id, + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + + const data = req.data; + const result = data.files![BUNDLE_FILE_PATH]!; + if (result.truncated && result.raw_url) { + return await fetch(result.raw_url).then(res => res.text()); + } + + return result?.content; + } catch (e) { + console.warn(`gist get: `, e) + } +} + +export async function deleteFile(id: string) { + try { + if (GIST_CACHE.has(id)) GIST_CACHE.delete(id); + + // 'DELETE /gists/{gist_id}' + const result = await octokit.rest.gists.delete({ + gist_id: id, + headers: { + 'X-GitHub-Api-Version': '2022-11-28' + } + }); + return result + } catch (e) { + console.warn(`gist delete: `, e); + } +} \ No newline at end of file diff --git a/edge/legacy/mod.ts b/services/legacy/mod.ts similarity index 100% rename from edge/legacy/mod.ts rename to services/legacy/mod.ts diff --git a/edge/legacy/parse-query.ts b/services/legacy/parse-query.ts similarity index 100% rename from edge/legacy/parse-query.ts rename to services/legacy/parse-query.ts diff --git a/services/legacy/style.ts b/services/legacy/style.ts new file mode 100644 index 0000000..9bc013f --- /dev/null +++ b/services/legacy/style.ts @@ -0,0 +1,85 @@ +export default ` +:root { + --black: #222; + --highlight: #ffcf00; + --red: #e54245; + --green: #608b00; + --blue: #0089b0; + --cyan: #008c98; + --magenta: #776eeb; + --yellow: #ffcf00; + --dim: #777; + --white: #fff; + --mono: "Noto Sans Mono", monospace; + color-scheme: dark light; +} + +.color-red { + color: var(--red); +} +.color-green { + color: var(--green); +} +.color-blue { + color: var(--blue); +} +.color-cyan { + color: var(--cyan); +} +.color-magenta { + color: var(--magenta); +} +.color-yellow { + color: var(--yellow); +} +.color-dim { + color: var(--dim); +} +.color-red-bg-red { + color: var(--red); + background-color: var(--red); +} +.color-red-bg-white { + color: var(--white); + background-color: var(--red); +} +.color-green-bg-green { + color: var(--green); + background-color: var(--green); +} +.color-green-bg-white { + color: var(--white); + background-color: var(--green); +} +.color-blue-bg-blue { + color: var(--blue); + background-color: var(--blue); +} +.color-blue-bg-white { + color: var(--white); + background-color: var(--blue); +} +.color-cyan-bg-cyan { + color: var(--cyan); + background-color: var(--cyan); +} +.color-cyan-bg-black { + color: var(--black); + background-color: var(--cyan); +} +.color-magenta-bg-magenta { + color: var(--magenta); + background-color: var(--magenta); +} +.color-magenta-bg-black { + color: var(--black); + background-color: var(--magenta); +} +.color-yellow-bg-yellow { + color: var(--yellow); + background-color: var(--yellow); +} +.color-yellow-bg-black { + color: var(--black); + background-color: var(--yellow); +}` \ No newline at end of file diff --git a/utils/server/mod.ts b/utils/server/mod.ts index 313d833..1c0f348 100644 --- a/utils/server/mod.ts +++ b/utils/server/mod.ts @@ -12,6 +12,8 @@ */ import type { ContentfulStatusCode } from 'hono/utils/http-status' +import type { LogLevel, LogRecord } from '@logtape/logtape' +import type { HonoContext } from '@logtape/hono' import { AsyncLocalStorage } from 'node:async_hooks' @@ -21,7 +23,6 @@ import { HTTPException } from 'hono/http-exception' import { Hono } from 'hono' import { cors } from 'hono/cors' -import type { LogLevel, LogRecord } from '@logtape/logtape' import { configure, getConsoleSink, getLogger as logtapeGetLogger } from '@logtape/logtape' import { getPrettyFormatter } from "@logtape/pretty"; @@ -163,20 +164,7 @@ function remapHttpLogRecord(record: LogRecord): LogRecord { }) } -function formatHttpRequestLog(c: { - req: { - method: string - path: string - url: string - header(name: string): string | undefined - } - res: { - status: number - headers: { - get(name: string): string | null - } - } -}, responseTime: number): Record { +function formatHttpRequestLog(c: HonoContext, responseTime: number): Record { return { method: c.req.method, path: c.req.path, @@ -194,13 +182,7 @@ const formatter = getPrettyFormatter({ timestamp: "none", // "time" | "date-time" | "date" | "rfc3339" | etc. // Customize icons - icons: { - trace: '🔹', - debug: '↪', - info: "ℹ️", - warning: '⚠️', - error: "🔥" - }, + icons: false, levelColors: { trace: 'rgb(56,189,248)', @@ -215,15 +197,14 @@ const formatter = getPrettyFormatter({ colors: true, // Category display - categoryWidth: 20, - categoryTruncate: "middle", // "middle" | "end" | false + categoryWidth: 10, + categoryTruncate: "end", // "middle" | "end" | false // Word wrapping wordWrap: false, // true | false | number // Show properties properties: false, - messageStyle: "italic" });