@sqaoss/flowy 1.3.0 → 1.4.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.
@@ -1,56 +1,156 @@
1
1
  import { getConfig } from './config.ts'
2
2
 
3
+ export interface GraphqlOptions {
4
+ /** Per-request timeout in milliseconds. Default 15s. */
5
+ timeoutMs?: number
6
+ /** Max retry attempts on transient transport failures. Default 2. */
7
+ retries?: number
8
+ /** Base backoff delay in milliseconds (exponential). Default 300ms. */
9
+ retryDelayMs?: number
10
+ }
11
+
12
+ const DEFAULT_TIMEOUT_MS = 15_000
13
+ const DEFAULT_RETRIES = 2
14
+ const DEFAULT_RETRY_DELAY_MS = 300
15
+
16
+ /** HTTP statuses worth retrying — transient server/proxy conditions. */
17
+ const TRANSIENT_STATUSES = new Set([429, 502, 503, 504])
18
+
19
+ type CodedError = Error & { code?: string }
20
+
21
+ function codedError(message: string, code: string): CodedError {
22
+ const err = new Error(message) as CodedError
23
+ err.code = code
24
+ return err
25
+ }
26
+
27
+ function isAbortError(error: unknown): boolean {
28
+ return (
29
+ error instanceof Error &&
30
+ (error.name === 'AbortError' || error.name === 'TimeoutError')
31
+ )
32
+ }
33
+
34
+ function sleep(ms: number): Promise<void> {
35
+ return new Promise((resolve) => setTimeout(resolve, ms))
36
+ }
37
+
38
+ async function readBody(res: Response): Promise<string> {
39
+ try {
40
+ return await res.text()
41
+ } catch {
42
+ return ''
43
+ }
44
+ }
45
+
3
46
  export async function graphql<T = unknown>(
4
47
  query: string,
5
48
  variables?: Record<string, unknown>,
49
+ options: GraphqlOptions = {},
6
50
  ): Promise<T> {
7
51
  const { apiUrl, apiKey } = getConfig()
52
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
53
+ const retries = options.retries ?? DEFAULT_RETRIES
54
+ const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS
8
55
 
9
56
  const headers: Record<string, string> = {
10
57
  'Content-Type': 'application/json',
11
58
  }
12
-
13
59
  if (apiKey) {
14
60
  headers.Authorization = `Bearer ${apiKey}`
15
61
  }
16
62
 
17
- const res = await fetch(apiUrl, {
18
- method: 'POST',
19
- headers,
20
- body: JSON.stringify({ query, variables }),
21
- })
63
+ const body = JSON.stringify({ query, variables })
22
64
 
23
- const json = (await res.json()) as {
24
- data?: T
25
- errors?: Array<{ message: string; extensions?: { code?: string } }>
26
- }
65
+ let lastError: CodedError | undefined
27
66
 
28
- if (json.errors?.length) {
29
- const error = json.errors[0]
30
- const code = error?.extensions?.code
67
+ for (let attempt = 0; attempt <= retries; attempt++) {
68
+ let res: Response
69
+ try {
70
+ res = await fetch(apiUrl, {
71
+ method: 'POST',
72
+ headers,
73
+ body,
74
+ signal: AbortSignal.timeout(timeoutMs),
75
+ })
76
+ } catch (error) {
77
+ // Network-layer failure: DNS, connection refused, or our timeout abort.
78
+ const message = isAbortError(error)
79
+ ? `Request timed out after ${timeoutMs}ms`
80
+ : `Network request failed: ${
81
+ error instanceof Error ? error.message : String(error)
82
+ }`
83
+ lastError = codedError(message, 'NETWORK_ERROR')
84
+ if (attempt < retries) {
85
+ await sleep(retryDelayMs * 2 ** attempt)
86
+ continue
87
+ }
88
+ throw lastError
89
+ }
31
90
 
32
- if (code === 'SUBSCRIPTION_REQUIRED') {
33
- throw new Error(
34
- 'An active subscription is required. Run `flowy billing checkout` to subscribe.',
91
+ // HTTP-level (non-2xx) failure handled before attempting to parse JSON,
92
+ // so proxy HTML / empty bodies never crash with a SyntaxError.
93
+ if (!res.ok) {
94
+ if (TRANSIENT_STATUSES.has(res.status) && attempt < retries) {
95
+ lastError = codedError(
96
+ `Server returned HTTP ${res.status}`,
97
+ 'SERVER_ERROR',
98
+ )
99
+ await sleep(retryDelayMs * 2 ** attempt)
100
+ continue
101
+ }
102
+ const snippet = (await readBody(res)).trim().slice(0, 200)
103
+ const detail = snippet ? `: ${snippet}` : ''
104
+ throw codedError(
105
+ `Server returned HTTP ${res.status}${detail}`,
106
+ 'SERVER_ERROR',
35
107
  )
36
108
  }
37
109
 
38
- if (code === 'SUBSCRIPTION_EXPIRED') {
39
- throw new Error(
40
- 'Your subscription has expired. Run `flowy billing checkout` to renew.',
110
+ type GraphqlBody = {
111
+ data?: T
112
+ errors?: Array<{ message: string; extensions?: { code?: string } }>
113
+ }
114
+ let json: GraphqlBody
115
+ try {
116
+ json = (await res.json()) as GraphqlBody
117
+ } catch {
118
+ const snippet = (await readBody(res)).trim().slice(0, 200)
119
+ const detail = snippet ? `: ${snippet}` : ''
120
+ throw codedError(
121
+ `Server returned a non-JSON response${detail}`,
122
+ 'SERVER_ERROR',
41
123
  )
42
124
  }
43
125
 
44
- if (code === 'SUBSCRIPTION_SUSPENDED') {
45
- throw new Error(
46
- 'Your subscription is suspended. Please contact support to resolve this.',
47
- )
126
+ if (json.errors?.length) {
127
+ const error = json.errors[0]
128
+ const code = error?.extensions?.code
129
+
130
+ if (code === 'SUBSCRIPTION_REQUIRED') {
131
+ throw new Error(
132
+ 'An active subscription is required. Run `flowy billing checkout` to subscribe.',
133
+ )
134
+ }
135
+ if (code === 'SUBSCRIPTION_EXPIRED') {
136
+ throw new Error(
137
+ 'Your subscription has expired. Run `flowy billing checkout` to renew.',
138
+ )
139
+ }
140
+ if (code === 'SUBSCRIPTION_SUSPENDED') {
141
+ throw new Error(
142
+ 'Your subscription is suspended. Please contact support to resolve this.',
143
+ )
144
+ }
145
+
146
+ const err = new Error(error?.message) as CodedError
147
+ if (code) err.code = code
148
+ throw err
48
149
  }
49
150
 
50
- const err = new Error(error?.message) as Error & { code?: string }
51
- if (code) err.code = code
52
- throw err
151
+ return json.data as T
53
152
  }
54
153
 
55
- return json.data as T
154
+ // Exhausted retries on a transient condition.
155
+ throw lastError ?? codedError('Request failed after retries', 'NETWORK_ERROR')
56
156
  }
@@ -1,7 +1,7 @@
1
- import { rmSync, writeFileSync } from 'node:fs'
1
+ import { existsSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, resolve } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
- import { afterEach, describe, expect, test } from 'vitest'
4
+ import { afterEach, describe, expect, test, vi } from 'vitest'
5
5
 
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url))
7
7
 
@@ -12,18 +12,70 @@ describe('resolveDescription', () => {
12
12
  try {
13
13
  rmSync(TEST_FILE)
14
14
  } catch {}
15
+ vi.restoreAllMocks()
15
16
  })
16
17
 
17
- test('returns file content when path exists', async () => {
18
+ test('returns --description verbatim, never as a file path', async () => {
19
+ // A literal value that also happens to be an existing path must NOT be read.
20
+ writeFileSync(TEST_FILE, '# File body\nshould not be used')
21
+ expect(existsSync(TEST_FILE)).toBe(true)
22
+
23
+ const { resolveDescription } = await import('./description.ts')
24
+ const result = await resolveDescription({ description: TEST_FILE })
25
+ expect(result).toBe(TEST_FILE)
26
+ })
27
+
28
+ test('returns the literal string identically regardless of CWD collisions', async () => {
29
+ const { resolveDescription } = await import('./description.ts')
30
+ const result = await resolveDescription({
31
+ description: 'Just a plain description',
32
+ })
33
+ expect(result).toBe('Just a plain description')
34
+ })
35
+
36
+ test('reads file contents when --description-file is given', async () => {
18
37
  writeFileSync(TEST_FILE, '# Test\nSome content')
19
38
  const { resolveDescription } = await import('./description.ts')
20
- const result = await resolveDescription(TEST_FILE)
39
+ const result = await resolveDescription({ descriptionFile: TEST_FILE })
21
40
  expect(result).toBe('# Test\nSome content')
22
41
  })
23
42
 
24
- test('returns value as-is when path does not exist', async () => {
43
+ test('reads stdin when --description-file is "-"', async () => {
25
44
  const { resolveDescription } = await import('./description.ts')
26
- const result = await resolveDescription('Just a plain description')
27
- expect(result).toBe('Just a plain description')
45
+ const fakeStdin = (async function* () {
46
+ yield Buffer.from('piped ')
47
+ yield Buffer.from('description')
48
+ })()
49
+ const result = await resolveDescription(
50
+ { descriptionFile: '-' },
51
+ // biome-ignore lint/suspicious/noExplicitAny: test stub for stdin
52
+ fakeStdin as any,
53
+ )
54
+ expect(result).toBe('piped description')
55
+ })
56
+
57
+ test('errors when --description-file points at a missing file', async () => {
58
+ const { resolveDescription } = await import('./description.ts')
59
+ await expect(
60
+ resolveDescription({ descriptionFile: resolve(__dirname, '../../nope') }),
61
+ ).rejects.toThrow(/not found|no such file|ENOENT/i)
62
+ })
63
+
64
+ test('errors when both --description and --description-file are given', async () => {
65
+ writeFileSync(TEST_FILE, 'body')
66
+ const { resolveDescription } = await import('./description.ts')
67
+ await expect(
68
+ resolveDescription({
69
+ description: 'literal',
70
+ descriptionFile: TEST_FILE,
71
+ }),
72
+ ).rejects.toThrow(/both|either|only one/i)
73
+ })
74
+
75
+ test('errors when neither --description nor --description-file is given', async () => {
76
+ const { resolveDescription } = await import('./description.ts')
77
+ await expect(resolveDescription({})).rejects.toThrow(
78
+ /--description|description-file|required/i,
79
+ )
28
80
  })
29
81
  })
@@ -1,8 +1,63 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
 
3
- export async function resolveDescription(value: string): Promise<string> {
4
- if (existsSync(value)) {
5
- return readFileSync(value, 'utf-8')
3
+ export interface DescriptionInput {
4
+ /** Literal description text. Used verbatim — never interpreted as a path. */
5
+ description?: string
6
+ /**
7
+ * Path to a file whose contents become the description.
8
+ * Use `-` to read from stdin.
9
+ */
10
+ descriptionFile?: string
11
+ }
12
+
13
+ type AsyncByteSource = AsyncIterable<Uint8Array | Buffer | string>
14
+
15
+ async function readStdin(source: AsyncByteSource): Promise<string> {
16
+ const chunks: string[] = []
17
+ for await (const chunk of source) {
18
+ chunks.push(
19
+ typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8'),
20
+ )
21
+ }
22
+ return chunks.join('')
23
+ }
24
+
25
+ /**
26
+ * Resolve a node description from explicit options.
27
+ *
28
+ * - `--description <text>` is always literal (never read as a file).
29
+ * - `--description-file <path>` reads file contents; `-` reads stdin.
30
+ * - Supplying both, or neither, is an error.
31
+ */
32
+ export async function resolveDescription(
33
+ input: DescriptionInput,
34
+ stdin: AsyncByteSource = process.stdin,
35
+ ): Promise<string> {
36
+ const hasLiteral = input.description != null
37
+ const hasFile = input.descriptionFile != null
38
+
39
+ if (hasLiteral && hasFile) {
40
+ throw new Error(
41
+ 'Pass only one of --description or --description-file, not both.',
42
+ )
43
+ }
44
+
45
+ if (hasLiteral) {
46
+ return input.description as string
6
47
  }
7
- return value
48
+
49
+ if (hasFile) {
50
+ const path = input.descriptionFile as string
51
+ if (path === '-') {
52
+ return readStdin(stdin)
53
+ }
54
+ if (!existsSync(path)) {
55
+ throw new Error(`--description-file not found: ${path}`)
56
+ }
57
+ return readFileSync(path, 'utf-8')
58
+ }
59
+
60
+ throw new Error(
61
+ 'A description is required: pass --description or --description-file.',
62
+ )
8
63
  }
@@ -45,4 +45,69 @@ describe('outputError', () => {
45
45
 
46
46
  expect(exitSpy).toHaveBeenCalledWith(1)
47
47
  })
48
+
49
+ test('exits 1 for VALIDATION_ERROR (usage/validation class)', () => {
50
+ vi.spyOn(console, 'error').mockImplementation(() => {})
51
+ const exitSpy = vi
52
+ .spyOn(process, 'exit')
53
+ .mockImplementation(() => undefined as never)
54
+
55
+ outputError(
56
+ Object.assign(new Error('bad input'), { code: 'VALIDATION_ERROR' }),
57
+ )
58
+
59
+ expect(exitSpy).toHaveBeenCalledWith(1)
60
+ })
61
+
62
+ test('exits 1 for CONFLICT (usage/validation class)', () => {
63
+ vi.spyOn(console, 'error').mockImplementation(() => {})
64
+ const exitSpy = vi
65
+ .spyOn(process, 'exit')
66
+ .mockImplementation(() => undefined as never)
67
+
68
+ outputError(Object.assign(new Error('conflict'), { code: 'CONFLICT' }))
69
+
70
+ expect(exitSpy).toHaveBeenCalledWith(1)
71
+ })
72
+
73
+ test('exits 2 for NOT_FOUND', () => {
74
+ vi.spyOn(console, 'error').mockImplementation(() => {})
75
+ const exitSpy = vi
76
+ .spyOn(process, 'exit')
77
+ .mockImplementation(() => undefined as never)
78
+
79
+ outputError(
80
+ Object.assign(new Error('Node bad-id not found'), { code: 'NOT_FOUND' }),
81
+ )
82
+
83
+ expect(exitSpy).toHaveBeenCalledWith(2)
84
+ })
85
+
86
+ test('exits 3 for SERVER_ERROR', () => {
87
+ vi.spyOn(console, 'error').mockImplementation(() => {})
88
+ const exitSpy = vi
89
+ .spyOn(process, 'exit')
90
+ .mockImplementation(() => undefined as never)
91
+
92
+ outputError(
93
+ Object.assign(new Error('Server returned HTTP 502'), {
94
+ code: 'SERVER_ERROR',
95
+ }),
96
+ )
97
+
98
+ expect(exitSpy).toHaveBeenCalledWith(3)
99
+ })
100
+
101
+ test('exits 4 for NETWORK_ERROR', () => {
102
+ vi.spyOn(console, 'error').mockImplementation(() => {})
103
+ const exitSpy = vi
104
+ .spyOn(process, 'exit')
105
+ .mockImplementation(() => undefined as never)
106
+
107
+ outputError(
108
+ Object.assign(new Error('Request timed out'), { code: 'NETWORK_ERROR' }),
109
+ )
110
+
111
+ expect(exitSpy).toHaveBeenCalledWith(4)
112
+ })
48
113
  })
@@ -2,6 +2,27 @@ export function output(data: unknown): void {
2
2
  console.log(JSON.stringify(data, null, 2))
3
3
  }
4
4
 
5
+ /**
6
+ * Map a GraphQL `extensions.code` (or transport code) to a distinct process
7
+ * exit code so callers can branch on failure class:
8
+ * 1 — usage / validation / conflict (default)
9
+ * 2 — not found
10
+ * 3 — server error (non-2xx, masked, non-JSON)
11
+ * 4 — network / transport (timeout, connection failure)
12
+ */
13
+ function exitCodeFor(code: string | undefined): number {
14
+ switch (code) {
15
+ case 'NOT_FOUND':
16
+ return 2
17
+ case 'SERVER_ERROR':
18
+ return 3
19
+ case 'NETWORK_ERROR':
20
+ return 4
21
+ default:
22
+ return 1
23
+ }
24
+ }
25
+
5
26
  export function outputError(error: unknown): void {
6
27
  const message = error instanceof Error ? error.message : String(error)
7
28
  const rawCode =
@@ -10,5 +31,5 @@ export function outputError(error: unknown): void {
10
31
  console.error(
11
32
  JSON.stringify(code ? { error: message, code } : { error: message }),
12
33
  )
13
- process.exit(1)
34
+ process.exit(exitCodeFor(code))
14
35
  }