@effect-gql/persisted-queries 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/errors.ts DELETED
@@ -1,107 +0,0 @@
1
- import { Data } from "effect"
2
-
3
- /**
4
- * GraphQL error format for APQ responses.
5
- * Compatible with Apollo Client's error handling.
6
- */
7
- export interface PersistedQueryGraphQLError {
8
- readonly message: string
9
- readonly extensions: {
10
- readonly code: string
11
- readonly [key: string]: unknown
12
- }
13
- }
14
-
15
- /**
16
- * Error returned when a persisted query hash is not found in the store
17
- * and no query body was provided.
18
- *
19
- * Apollo clients recognize this error and automatically retry with the full query.
20
- * This is the expected flow for Automatic Persisted Queries (APQ).
21
- */
22
- export class PersistedQueryNotFoundError extends Data.TaggedError("PersistedQueryNotFoundError")<{
23
- readonly hash: string
24
- }> {
25
- /**
26
- * Convert to GraphQL error format compatible with Apollo protocol.
27
- */
28
- toGraphQLError(): PersistedQueryGraphQLError {
29
- return {
30
- message: "PersistedQueryNotFound",
31
- extensions: {
32
- code: "PERSISTED_QUERY_NOT_FOUND",
33
- },
34
- }
35
- }
36
- }
37
-
38
- /**
39
- * Error returned when the persisted query protocol version is not supported.
40
- *
41
- * Currently only version 1 is supported, which uses SHA-256 hashing.
42
- */
43
- export class PersistedQueryVersionError extends Data.TaggedError("PersistedQueryVersionError")<{
44
- readonly version: number
45
- }> {
46
- toGraphQLError(): PersistedQueryGraphQLError {
47
- return {
48
- message: `Unsupported persisted query version: ${this.version}`,
49
- extensions: {
50
- code: "PERSISTED_QUERY_VERSION_NOT_SUPPORTED",
51
- version: this.version,
52
- },
53
- }
54
- }
55
- }
56
-
57
- /**
58
- * Error returned when the provided query doesn't match its hash.
59
- *
60
- * This can indicate a client bug or a potential hash collision attack.
61
- * Hash validation is enabled by default and can be disabled if needed.
62
- */
63
- export class PersistedQueryHashMismatchError extends Data.TaggedError(
64
- "PersistedQueryHashMismatchError"
65
- )<{
66
- readonly providedHash: string
67
- readonly computedHash: string
68
- }> {
69
- toGraphQLError(): PersistedQueryGraphQLError {
70
- return {
71
- message: "Query hash does not match provided hash",
72
- extensions: {
73
- code: "PERSISTED_QUERY_HASH_MISMATCH",
74
- },
75
- }
76
- }
77
- }
78
-
79
- /**
80
- * Error returned when trying to execute a query that is not in the safelist.
81
- *
82
- * In safelist mode, only pre-registered queries are allowed.
83
- * This error is returned when a client tries to register a new query.
84
- */
85
- export class PersistedQueryNotAllowedError extends Data.TaggedError(
86
- "PersistedQueryNotAllowedError"
87
- )<{
88
- readonly hash: string
89
- }> {
90
- toGraphQLError(): PersistedQueryGraphQLError {
91
- return {
92
- message: "Query not in safelist",
93
- extensions: {
94
- code: "PERSISTED_QUERY_NOT_ALLOWED",
95
- },
96
- }
97
- }
98
- }
99
-
100
- /**
101
- * Union type of all APQ-related errors
102
- */
103
- export type PersistedQueryError =
104
- | PersistedQueryNotFoundError
105
- | PersistedQueryVersionError
106
- | PersistedQueryHashMismatchError
107
- | PersistedQueryNotAllowedError
package/src/index.ts DELETED
@@ -1,77 +0,0 @@
1
- /**
2
- * @effect-gql/persisted-queries
3
- *
4
- * Apollo Persisted Queries support for Effect GraphQL.
5
- *
6
- * Supports both Automatic Persisted Queries (APQ) mode for runtime registration
7
- * and Safelist mode for pre-registered query allowlisting.
8
- *
9
- * ## Quick Start
10
- *
11
- * @example APQ Mode (runtime registration)
12
- * ```typescript
13
- * import { makePersistedQueriesRouter } from "@effect-gql/persisted-queries"
14
- *
15
- * const router = makePersistedQueriesRouter(schema, serviceLayer, {
16
- * mode: "apq",
17
- * enableGet: true, // Enable CDN caching
18
- * graphiql: { path: "/graphiql" },
19
- * })
20
- * ```
21
- *
22
- * @example Safelist Mode (pre-registered queries only)
23
- * ```typescript
24
- * import { makePersistedQueriesRouter, makeSafelistStore } from "@effect-gql/persisted-queries"
25
- *
26
- * const router = makePersistedQueriesRouter(schema, serviceLayer, {
27
- * mode: "safelist",
28
- * store: makeSafelistStore({
29
- * "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38": "query GetUser($id: ID!) { user(id: $id) { name email } }",
30
- * "a1b2c3d4...": "query GetPosts { posts { title } }",
31
- * }),
32
- * })
33
- * ```
34
- *
35
- * @example Custom Store Size
36
- * ```typescript
37
- * import { makePersistedQueriesRouter, makeMemoryStore } from "@effect-gql/persisted-queries"
38
- *
39
- * const router = makePersistedQueriesRouter(schema, serviceLayer, {
40
- * store: makeMemoryStore({ maxSize: 5000 }),
41
- * })
42
- * ```
43
- *
44
- * @packageDocumentation
45
- */
46
-
47
- // Router
48
- export { makePersistedQueriesRouter } from "./persisted-queries-router"
49
-
50
- // Store interface and implementations
51
- export { PersistedQueryStore } from "./store"
52
- export type { PersistedQueryStore as PersistedQueryStoreInterface } from "./store"
53
- export { makeMemoryStore, makeSafelistStore } from "./memory-store"
54
- export type { MemoryStoreConfig } from "./memory-store"
55
-
56
- // Configuration
57
- export type {
58
- PersistedQueriesConfig,
59
- PersistedQueriesRouterOptions,
60
- PersistedQueryMode,
61
- HashAlgorithm,
62
- } from "./config"
63
- export { PersistedQueriesConfigFromEnv } from "./config"
64
-
65
- // Errors
66
- export {
67
- PersistedQueryNotFoundError,
68
- PersistedQueryVersionError,
69
- PersistedQueryHashMismatchError,
70
- PersistedQueryNotAllowedError,
71
- type PersistedQueryError,
72
- type PersistedQueryGraphQLError,
73
- } from "./errors"
74
-
75
- // Utilities
76
- export { computeHash, parsePersistedQueryExtension, parseGetRequestBody } from "./utils"
77
- export type { PersistedQueryExtension, GraphQLRequestBody } from "./utils"
@@ -1,133 +0,0 @@
1
- import { Effect, Layer, Option } from "effect"
2
- import { PersistedQueryStore } from "./store"
3
-
4
- /**
5
- * Configuration for the in-memory LRU store
6
- */
7
- export interface MemoryStoreConfig {
8
- /**
9
- * Maximum number of queries to cache.
10
- * When exceeded, least recently used queries are evicted.
11
- * Default: 1000
12
- */
13
- readonly maxSize?: number
14
- }
15
-
16
- /**
17
- * Create an in-memory LRU (Least Recently Used) store for persisted queries.
18
- *
19
- * This implementation uses Map's natural insertion order for O(1) LRU operations:
20
- * - get: O(1) - delete and re-insert to move to end (most recently used)
21
- * - set: O(1) - insert at end, evict from front if needed
22
- * - eviction: O(1) - delete first entry (least recently used)
23
- *
24
- * This is the default store implementation suitable for single-instance servers.
25
- * For multi-instance deployments, consider using a shared store like Redis.
26
- *
27
- * @param config - Optional configuration for cache size
28
- * @returns A Layer providing the PersistedQueryStore service
29
- *
30
- * @example
31
- * ```typescript
32
- * import { makeMemoryStore, makePersistedQueriesRouter } from "@effect-gql/persisted-queries"
33
- *
34
- * // Default store with 1000 entry limit
35
- * const router1 = makePersistedQueriesRouter(schema, serviceLayer)
36
- *
37
- * // Custom store with larger cache
38
- * const router2 = makePersistedQueriesRouter(schema, serviceLayer, {
39
- * store: makeMemoryStore({ maxSize: 5000 })
40
- * })
41
- * ```
42
- */
43
- export const makeMemoryStore = (
44
- config: MemoryStoreConfig = {}
45
- ): Layer.Layer<PersistedQueryStore> => {
46
- const maxSize = config.maxSize ?? 1000
47
-
48
- // Map maintains insertion order - we use this for O(1) LRU
49
- // First entry = least recently used, last entry = most recently used
50
- const cache = new Map<string, string>()
51
-
52
- // Move entry to end (most recently used) by deleting and re-inserting
53
- const touch = (hash: string, query: string): void => {
54
- cache.delete(hash)
55
- cache.set(hash, query)
56
- }
57
-
58
- // Evict oldest entry (first in Map) if over capacity - O(1)
59
- const evictIfNeeded = (): void => {
60
- if (cache.size <= maxSize) return
61
- // Map.keys().next() gives us the first (oldest) key in O(1)
62
- const oldestKey = cache.keys().next().value
63
- if (oldestKey !== undefined) {
64
- cache.delete(oldestKey)
65
- }
66
- }
67
-
68
- return Layer.succeed(
69
- PersistedQueryStore,
70
- PersistedQueryStore.of({
71
- get: (hash) =>
72
- Effect.sync(() => {
73
- const query = cache.get(hash)
74
- if (query === undefined) {
75
- return Option.none<string>()
76
- }
77
- // Move to end (most recently used)
78
- touch(hash, query)
79
- return Option.some(query)
80
- }),
81
-
82
- set: (hash, query) =>
83
- Effect.sync(() => {
84
- // If key exists, delete first to ensure it moves to end
85
- cache.delete(hash)
86
- cache.set(hash, query)
87
- evictIfNeeded()
88
- }),
89
-
90
- has: (hash) => Effect.sync(() => cache.has(hash)),
91
- })
92
- )
93
- }
94
-
95
- /**
96
- * Create a pre-populated safelist store.
97
- *
98
- * This store only allows queries that were provided at creation time.
99
- * Any attempt to store new queries is silently ignored.
100
- * Use this for production security where you want to allowlist specific operations.
101
- *
102
- * @param queries - Record mapping SHA-256 hashes to query strings
103
- * @returns A Layer providing the PersistedQueryStore service
104
- *
105
- * @example
106
- * ```typescript
107
- * import { makeSafelistStore, makePersistedQueriesRouter } from "@effect-gql/persisted-queries"
108
- *
109
- * // Pre-register allowed queries
110
- * const router = makePersistedQueriesRouter(schema, serviceLayer, {
111
- * mode: "safelist",
112
- * store: makeSafelistStore({
113
- * "ecf4edb46db40b5132295c0291d62fb65d6759a9eedfa4d5d612dd5ec54a6b38": "query GetUser($id: ID!) { user(id: $id) { name email } }",
114
- * "a1b2c3d4...": "query GetPosts { posts { title } }",
115
- * }),
116
- * })
117
- * ```
118
- */
119
- export const makeSafelistStore = (
120
- queries: Record<string, string>
121
- ): Layer.Layer<PersistedQueryStore> =>
122
- Layer.succeed(
123
- PersistedQueryStore,
124
- PersistedQueryStore.of({
125
- get: (hash) =>
126
- Effect.succeed(queries[hash] !== undefined ? Option.some(queries[hash]) : Option.none()),
127
-
128
- // No-op for safelist mode - queries cannot be added at runtime
129
- set: () => Effect.void,
130
-
131
- has: (hash) => Effect.succeed(queries[hash] !== undefined),
132
- })
133
- )