@ttctl/core 0.0.0 → 0.1.0-rc.1

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.
Files changed (195) hide show
  1. package/README.md +49 -9
  2. package/dist/__generated__/gateway.d.ts +4546 -0
  3. package/dist/__generated__/gateway.d.ts.map +1 -0
  4. package/dist/__generated__/gateway.js +9 -0
  5. package/dist/__generated__/gateway.js.map +1 -0
  6. package/dist/__generated__/talent-profile-zod-schemas.d.ts +1187 -0
  7. package/dist/__generated__/talent-profile-zod-schemas.d.ts.map +1 -0
  8. package/dist/__generated__/talent-profile-zod-schemas.js +1136 -0
  9. package/dist/__generated__/talent-profile-zod-schemas.js.map +1 -0
  10. package/dist/__generated__/talent-profile.d.ts +1397 -0
  11. package/dist/__generated__/talent-profile.d.ts.map +1 -0
  12. package/dist/__generated__/talent-profile.js +9 -0
  13. package/dist/__generated__/talent-profile.js.map +1 -0
  14. package/dist/__generated__/zod-schemas.d.ts +2895 -0
  15. package/dist/__generated__/zod-schemas.d.ts.map +1 -0
  16. package/dist/__generated__/zod-schemas.js +3121 -0
  17. package/dist/__generated__/zod-schemas.js.map +1 -0
  18. package/dist/__tests__/fixtures/profile/builders.d.ts +74 -0
  19. package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -0
  20. package/dist/__tests__/fixtures/profile/builders.js +196 -0
  21. package/dist/__tests__/fixtures/profile/builders.js.map +1 -0
  22. package/dist/__tests__/fixtures/profile/data.d.ts +39 -0
  23. package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -0
  24. package/dist/__tests__/fixtures/profile/data.js +230 -0
  25. package/dist/__tests__/fixtures/profile/data.js.map +1 -0
  26. package/dist/__tests__/fixtures/profile/index.d.ts +9 -0
  27. package/dist/__tests__/fixtures/profile/index.d.ts.map +1 -0
  28. package/dist/__tests__/fixtures/profile/index.js +10 -0
  29. package/dist/__tests__/fixtures/profile/index.js.map +1 -0
  30. package/dist/__tests__/fixtures/profile/types.d.ts +53 -0
  31. package/dist/__tests__/fixtures/profile/types.d.ts.map +1 -0
  32. package/dist/__tests__/fixtures/profile/types.js +4 -0
  33. package/dist/__tests__/fixtures/profile/types.js.map +1 -0
  34. package/dist/auth/errors.d.ts +82 -0
  35. package/dist/auth/errors.d.ts.map +1 -0
  36. package/dist/auth/errors.js +68 -0
  37. package/dist/auth/errors.js.map +1 -0
  38. package/dist/auth.d.ts +192 -0
  39. package/dist/auth.d.ts.map +1 -0
  40. package/dist/auth.js +294 -0
  41. package/dist/auth.js.map +1 -0
  42. package/dist/config.d.ts +212 -0
  43. package/dist/config.d.ts.map +1 -0
  44. package/dist/config.js +349 -0
  45. package/dist/config.js.map +1 -0
  46. package/dist/configLock.d.ts +50 -0
  47. package/dist/configLock.d.ts.map +1 -0
  48. package/dist/configLock.js +88 -0
  49. package/dist/configLock.js.map +1 -0
  50. package/dist/configWriter.d.ts +97 -0
  51. package/dist/configWriter.d.ts.map +1 -0
  52. package/dist/configWriter.js +687 -0
  53. package/dist/configWriter.js.map +1 -0
  54. package/dist/index.d.ts +37 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +28 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/kill-switch.d.ts +161 -0
  59. package/dist/kill-switch.d.ts.map +1 -0
  60. package/dist/kill-switch.js +235 -0
  61. package/dist/kill-switch.js.map +1 -0
  62. package/dist/lib/date.d.ts +58 -0
  63. package/dist/lib/date.d.ts.map +1 -0
  64. package/dist/lib/date.js +104 -0
  65. package/dist/lib/date.js.map +1 -0
  66. package/dist/lib/diagnostic-log.d.ts +159 -0
  67. package/dist/lib/diagnostic-log.d.ts.map +1 -0
  68. package/dist/lib/diagnostic-log.js +186 -0
  69. package/dist/lib/diagnostic-log.js.map +1 -0
  70. package/dist/lib/package-version.d.ts +19 -0
  71. package/dist/lib/package-version.d.ts.map +1 -0
  72. package/dist/lib/package-version.js +38 -0
  73. package/dist/lib/package-version.js.map +1 -0
  74. package/dist/lib/redact.d.ts +153 -0
  75. package/dist/lib/redact.d.ts.map +1 -0
  76. package/dist/lib/redact.js +207 -0
  77. package/dist/lib/redact.js.map +1 -0
  78. package/dist/lib/text.d.ts +14 -0
  79. package/dist/lib/text.d.ts.map +1 -0
  80. package/dist/lib/text.js +21 -0
  81. package/dist/lib/text.js.map +1 -0
  82. package/dist/lib/wire-shape.d.ts +131 -0
  83. package/dist/lib/wire-shape.d.ts.map +1 -0
  84. package/dist/lib/wire-shape.js +376 -0
  85. package/dist/lib/wire-shape.js.map +1 -0
  86. package/dist/onepassword.d.ts +29 -0
  87. package/dist/onepassword.d.ts.map +1 -0
  88. package/dist/onepassword.js +112 -0
  89. package/dist/onepassword.js.map +1 -0
  90. package/dist/services/_shared/transport.d.ts +148 -0
  91. package/dist/services/_shared/transport.d.ts.map +1 -0
  92. package/dist/services/_shared/transport.js +102 -0
  93. package/dist/services/_shared/transport.js.map +1 -0
  94. package/dist/services/applications/index.d.ts +210 -0
  95. package/dist/services/applications/index.d.ts.map +1 -0
  96. package/dist/services/applications/index.js +240 -0
  97. package/dist/services/applications/index.js.map +1 -0
  98. package/dist/services/availability/index.d.ts +254 -0
  99. package/dist/services/availability/index.d.ts.map +1 -0
  100. package/dist/services/availability/index.js +310 -0
  101. package/dist/services/availability/index.js.map +1 -0
  102. package/dist/services/contracts/index.d.ts +132 -0
  103. package/dist/services/contracts/index.d.ts.map +1 -0
  104. package/dist/services/contracts/index.js +211 -0
  105. package/dist/services/contracts/index.js.map +1 -0
  106. package/dist/services/engagements/index.d.ts +504 -0
  107. package/dist/services/engagements/index.d.ts.map +1 -0
  108. package/dist/services/engagements/index.js +613 -0
  109. package/dist/services/engagements/index.js.map +1 -0
  110. package/dist/services/jobs/index.d.ts +490 -0
  111. package/dist/services/jobs/index.d.ts.map +1 -0
  112. package/dist/services/jobs/index.js +753 -0
  113. package/dist/services/jobs/index.js.map +1 -0
  114. package/dist/services/payments/index.d.ts +415 -0
  115. package/dist/services/payments/index.d.ts.map +1 -0
  116. package/dist/services/payments/index.js +636 -0
  117. package/dist/services/payments/index.js.map +1 -0
  118. package/dist/services/profile/__tests__/fixtures.d.ts +214 -0
  119. package/dist/services/profile/__tests__/fixtures.d.ts.map +1 -0
  120. package/dist/services/profile/__tests__/fixtures.js +176 -0
  121. package/dist/services/profile/__tests__/fixtures.js.map +1 -0
  122. package/dist/services/profile/basic/index.d.ts +390 -0
  123. package/dist/services/profile/basic/index.d.ts.map +1 -0
  124. package/dist/services/profile/basic/index.js +1007 -0
  125. package/dist/services/profile/basic/index.js.map +1 -0
  126. package/dist/services/profile/certifications/index.d.ts +74 -0
  127. package/dist/services/profile/certifications/index.d.ts.map +1 -0
  128. package/dist/services/profile/certifications/index.js +169 -0
  129. package/dist/services/profile/certifications/index.js.map +1 -0
  130. package/dist/services/profile/education/index.d.ts +73 -0
  131. package/dist/services/profile/education/index.d.ts.map +1 -0
  132. package/dist/services/profile/education/index.js +168 -0
  133. package/dist/services/profile/education/index.js.map +1 -0
  134. package/dist/services/profile/employment/index.d.ts +111 -0
  135. package/dist/services/profile/employment/index.d.ts.map +1 -0
  136. package/dist/services/profile/employment/index.js +202 -0
  137. package/dist/services/profile/employment/index.js.map +1 -0
  138. package/dist/services/profile/external/index.d.ts +219 -0
  139. package/dist/services/profile/external/index.d.ts.map +1 -0
  140. package/dist/services/profile/external/index.js +560 -0
  141. package/dist/services/profile/external/index.js.map +1 -0
  142. package/dist/services/profile/index.d.ts +24 -0
  143. package/dist/services/profile/index.d.ts.map +1 -0
  144. package/dist/services/profile/index.js +26 -0
  145. package/dist/services/profile/index.js.map +1 -0
  146. package/dist/services/profile/industries/index.d.ts +130 -0
  147. package/dist/services/profile/industries/index.d.ts.map +1 -0
  148. package/dist/services/profile/industries/index.js +292 -0
  149. package/dist/services/profile/industries/index.js.map +1 -0
  150. package/dist/services/profile/portfolio/index.d.ts +352 -0
  151. package/dist/services/profile/portfolio/index.d.ts.map +1 -0
  152. package/dist/services/profile/portfolio/index.js +833 -0
  153. package/dist/services/profile/portfolio/index.js.map +1 -0
  154. package/dist/services/profile/resume/index.d.ts +60 -0
  155. package/dist/services/profile/resume/index.d.ts.map +1 -0
  156. package/dist/services/profile/resume/index.js +212 -0
  157. package/dist/services/profile/resume/index.js.map +1 -0
  158. package/dist/services/profile/reviews/index.d.ts +137 -0
  159. package/dist/services/profile/reviews/index.d.ts.map +1 -0
  160. package/dist/services/profile/reviews/index.js +431 -0
  161. package/dist/services/profile/reviews/index.js.map +1 -0
  162. package/dist/services/profile/shared.d.ts +127 -0
  163. package/dist/services/profile/shared.d.ts.map +1 -0
  164. package/dist/services/profile/shared.js +155 -0
  165. package/dist/services/profile/shared.js.map +1 -0
  166. package/dist/services/profile/skills/index.d.ts +212 -0
  167. package/dist/services/profile/skills/index.d.ts.map +1 -0
  168. package/dist/services/profile/skills/index.js +461 -0
  169. package/dist/services/profile/skills/index.js.map +1 -0
  170. package/dist/services/profile/visas/index.d.ts +74 -0
  171. package/dist/services/profile/visas/index.d.ts.map +1 -0
  172. package/dist/services/profile/visas/index.js +306 -0
  173. package/dist/services/profile/visas/index.js.map +1 -0
  174. package/dist/services/timesheet/index.d.ts +326 -0
  175. package/dist/services/timesheet/index.d.ts.map +1 -0
  176. package/dist/services/timesheet/index.js +324 -0
  177. package/dist/services/timesheet/index.js.map +1 -0
  178. package/dist/services/translations.d.ts +79 -0
  179. package/dist/services/translations.d.ts.map +1 -0
  180. package/dist/services/translations.js +136 -0
  181. package/dist/services/translations.js.map +1 -0
  182. package/dist/transport-resilience.d.ts +136 -0
  183. package/dist/transport-resilience.d.ts.map +1 -0
  184. package/dist/transport-resilience.js +247 -0
  185. package/dist/transport-resilience.js.map +1 -0
  186. package/dist/transport.d.ts +408 -0
  187. package/dist/transport.d.ts.map +1 -0
  188. package/dist/transport.js +691 -0
  189. package/dist/transport.js.map +1 -0
  190. package/dist/types.d.ts +41 -0
  191. package/dist/types.d.ts.map +1 -0
  192. package/dist/types.js +18 -0
  193. package/dist/types.js.map +1 -0
  194. package/package.json +40 -12
  195. package/index.js +0 -7
@@ -0,0 +1,753 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Oleksii PELYKH
3
+ import { buildDryRunPreview } from "../../transport.js";
4
+ import { callGatewayShared } from "../_shared/transport.js";
5
+ export class JobsError extends Error {
6
+ code;
7
+ name = "JobsError";
8
+ constructor(code, message, options) {
9
+ super(message, options);
10
+ this.code = code;
11
+ }
12
+ }
13
+ /**
14
+ * Default values for {@link ListOptions} pagination fields when the
15
+ * caller does not specify them. Mirrors the pre-#138 hardcoded values
16
+ * in `JOBS_LIST_QUERY` (`page: 0, pageSize: 20` on the wire — `page:
17
+ * 1, perPage: 20` user-facing). Exposed so tests can assert against
18
+ * the same constants the production code uses.
19
+ */
20
+ export const DEFAULT_PAGE = 1;
21
+ export const DEFAULT_PER_PAGE = 20;
22
+ // ---------------------------------------------------------------------
23
+ // GraphQL operation strings (full-document queries — no APQ pinning)
24
+ //
25
+ // Mirror the captured documents in
26
+ // `../research/graphql/gateway/operations/mobile/`, but with selection
27
+ // sets trimmed to the shape this service surfaces. Operation NAMES are
28
+ // kept distinct from the captured names because (a) we trim aggressively
29
+ // and (b) the captured names refer to flows we don't implement (e.g.
30
+ // `InitialJobs` selects fragments unrelated to this service's contract).
31
+ // ---------------------------------------------------------------------
32
+ // Pagination variable types (issue #138):
33
+ //
34
+ // - `$page: Int` — nullable Int; server defaults to 0 when omitted.
35
+ // Verified empirically: BlogPosts, GetJobsForDashboard, and
36
+ // GetTalentReferralTrackers (in
37
+ // `research/graphql/gateway/operations/`) all declare `$page: Int`.
38
+ //
39
+ // - `$pageSize: PageSize` — CUSTOM SCALAR, NOT `Int`. Captured operations
40
+ // use the named `PageSize` scalar. The pre-#138 hardcoded literal
41
+ // `pageSize: 20` worked because GraphQL accepts integer literals for
42
+ // custom scalars without type-checking; once we extracted the value
43
+ // to a variable, the server validated and (correctly) rejected
44
+ // `Int` in a `PageSize`-typed position. The fix is to declare the
45
+ // variable with the actual server type. Verified empirically: live
46
+ // API returned HTTP 400 `Variable "$pageSize" of type "Int!" used
47
+ // in position expecting type "PageSize"` during E2E pre-merge
48
+ // verification — schema/contract validation rule caught it.
49
+ const JOBS_LIST_QUERY = `query JobsList($skills: [String!], $keywords: [String!], $excludeSkills: [String!], $excludeKeywords: [String!], $commitments: [JobCommitmentFilterEnum!], $workTypes: [JobWorkTypeSlug!], $estimatedLengths: [EstimatedLengthFilterEnum!], $notInterested: BooleanFilter, $saved: BooleanFilter, $sortTarget: String, $page: Int, $pageSize: PageSize) {
50
+ viewer {
51
+ __typename
52
+ id
53
+ eligibleJobs(
54
+ page: $page
55
+ pageSize: $pageSize
56
+ sortTarget: $sortTarget
57
+ skills: $skills
58
+ keywords: $keywords
59
+ excludeSkills: $excludeSkills
60
+ excludeKeywords: $excludeKeywords
61
+ commitmentsV2: $commitments
62
+ workTypesV2: $workTypes
63
+ estimatedLengths: $estimatedLengths
64
+ filter: { notInterested: $notInterested, saved: $saved }
65
+ ) {
66
+ __typename
67
+ entities {
68
+ __typename
69
+ id
70
+ title
71
+ url
72
+ commitment { __typename slug }
73
+ workType { __typename slug }
74
+ specialization { __typename title }
75
+ expectedHours
76
+ maxRate
77
+ startDate
78
+ postedWhen
79
+ viewed
80
+ saved
81
+ notInterested
82
+ client { __typename id fullName }
83
+ }
84
+ totalCount
85
+ }
86
+ }
87
+ }`;
88
+ const JOB_SHOW_QUERY = `query JobShow($id: ID!) {
89
+ viewer {
90
+ __typename
91
+ id
92
+ job(id: $id) {
93
+ __typename
94
+ id
95
+ title
96
+ url
97
+ descriptionMd
98
+ commitment { __typename slug }
99
+ workType { __typename slug }
100
+ specialization { __typename title }
101
+ expectedHours
102
+ minimumHoursPerBillingCycle
103
+ maxRate
104
+ startDate
105
+ postedWhen
106
+ viewed
107
+ saved
108
+ notInterested
109
+ isCoaching
110
+ isToptalProject
111
+ semiMonthlyBilling
112
+ positionsCount
113
+ jobTimeZone {
114
+ __typename
115
+ verbose
116
+ hoursOverlap
117
+ workingTimeFrom
118
+ workingTimeTo
119
+ }
120
+ client {
121
+ __typename
122
+ id
123
+ city
124
+ countryName
125
+ fullName
126
+ industry
127
+ isEnterprise
128
+ website
129
+ linkedin
130
+ teamSize { __typename value }
131
+ }
132
+ jobSkillSetsV2 {
133
+ __typename
134
+ edges {
135
+ __typename
136
+ node {
137
+ __typename
138
+ rating
139
+ isOptional
140
+ theSkill { __typename id name }
141
+ }
142
+ }
143
+ }
144
+ languages { __typename id name }
145
+ }
146
+ }
147
+ }`;
148
+ const MARK_JOB_SAVED_MUTATION = `mutation JobMarkSaved($jobID: ID!) {
149
+ job(id: $jobID) {
150
+ __typename
151
+ markSaved(input: {}) {
152
+ __typename
153
+ success
154
+ errors { __typename key message code }
155
+ job { __typename id saved notInterested viewed }
156
+ }
157
+ }
158
+ }`;
159
+ const MARK_JOB_NOT_INTERESTED_MUTATION = `mutation JobMarkNotInterested($jobID: ID!, $reason: String!) {
160
+ job(id: $jobID) {
161
+ __typename
162
+ markNotInterested(input: { reason: $reason }) {
163
+ __typename
164
+ success
165
+ errors { __typename key message code }
166
+ job { __typename id saved notInterested viewed }
167
+ }
168
+ }
169
+ }`;
170
+ const MARK_JOB_VIEWED_MUTATION = `mutation JobMarkViewed($jobID: ID!) {
171
+ job(id: $jobID) {
172
+ __typename
173
+ markViewed(input: {}) {
174
+ __typename
175
+ success
176
+ errors { __typename key message code }
177
+ job { __typename id saved notInterested viewed }
178
+ }
179
+ }
180
+ }`;
181
+ const CLEAR_JOB_INTEREST_MUTATION = `mutation JobClearInterest($jobID: ID!) {
182
+ job(id: $jobID) {
183
+ __typename
184
+ clearInterestStatus(input: {}) {
185
+ __typename
186
+ success
187
+ errors { __typename key message code }
188
+ job { __typename id saved notInterested viewed }
189
+ }
190
+ }
191
+ }`;
192
+ const JOB_SEARCH_SUBSCRIPTION_QUERY = `query JobSearchSubscriptionShow {
193
+ viewer {
194
+ __typename
195
+ id
196
+ searchSubscription {
197
+ __typename
198
+ skills
199
+ keywords
200
+ excludeSkills
201
+ excludeKeywords
202
+ commitmentsV2
203
+ workTypesV2
204
+ estimatedLengths
205
+ excludeUnspecifiedBudget
206
+ }
207
+ }
208
+ }`;
209
+ const START_JOB_SUBSCRIPTION_MUTATION = `mutation JobSearchSubscriptionStart($skills: [String!], $keywords: [String!], $excludeSkills: [String!], $excludeKeywords: [String!], $excludeUnspecifiedBudget: Boolean, $commitments: [JobCommitmentFilterEnum!], $workTypes: [JobWorkTypeSlug!], $estimatedLengths: [EstimatedLengthFilterEnum!]) {
210
+ searchSubscription {
211
+ __typename
212
+ start(input: {
213
+ skills: $skills
214
+ keywords: $keywords
215
+ excludeSkills: $excludeSkills
216
+ excludeKeywords: $excludeKeywords
217
+ excludeUnspecifiedBudget: $excludeUnspecifiedBudget
218
+ commitmentsV2: $commitments
219
+ workTypesV2: $workTypes
220
+ estimatedLengths: $estimatedLengths
221
+ }) {
222
+ __typename
223
+ success
224
+ errors { __typename key message code }
225
+ viewer {
226
+ __typename
227
+ id
228
+ searchSubscription {
229
+ __typename
230
+ skills
231
+ keywords
232
+ excludeSkills
233
+ excludeKeywords
234
+ commitmentsV2
235
+ workTypesV2
236
+ estimatedLengths
237
+ excludeUnspecifiedBudget
238
+ }
239
+ }
240
+ }
241
+ }
242
+ }`;
243
+ const TERMINATE_JOB_SUBSCRIPTION_MUTATION = `mutation JobSearchSubscriptionTerminate {
244
+ searchSubscription {
245
+ __typename
246
+ terminate(input: {}) {
247
+ __typename
248
+ success
249
+ errors { __typename key message code }
250
+ }
251
+ }
252
+ }`;
253
+ // The mobile-gateway returns at least two distinct GraphQL error
254
+ // messages that both mean "no such job from the caller's perspective":
255
+ // - `"Record not found"` for some lookup paths (kept for safety;
256
+ // originally inferred, not yet observed)
257
+ // - `"Invalid ID"` for malformed/unparseable IDs (live-
258
+ // observed during #148 E2E — see #166)
259
+ // A successful response with `viewer.eligibleJob === null` is the
260
+ // third (live-observed) NOT_FOUND signal; that branch is handled
261
+ // inline in `show()` and `markViewed()` and is not regex-driven.
262
+ const NOT_FOUND_MESSAGE_PATTERN = /Record not found|Invalid ID/i;
263
+ /**
264
+ * Thin per-service wrapper around {@link callGatewayShared} (issue
265
+ * #329). Pins the mobile-gateway surface and the {@link JobsError}
266
+ * domain class.
267
+ */
268
+ async function callGateway(token, operationName, query, variables, schema) {
269
+ return callGatewayShared("mobile-gateway", token, operationName, query, variables, JobsError, {
270
+ schema,
271
+ });
272
+ }
273
+ function projectListItem(entity) {
274
+ return {
275
+ id: entity.id,
276
+ title: entity.title,
277
+ url: entity.url,
278
+ client: entity.client,
279
+ commitment: entity.commitment,
280
+ workType: entity.workType,
281
+ specialization: entity.specialization,
282
+ expectedHours: entity.expectedHours,
283
+ maxRate: entity.maxRate,
284
+ startDate: entity.startDate,
285
+ postedWhen: entity.postedWhen,
286
+ viewed: entity.viewed,
287
+ saved: entity.saved,
288
+ notInterested: entity.notInterested,
289
+ };
290
+ }
291
+ function projectJobDetail(entity) {
292
+ const skills = [];
293
+ const edges = entity.jobSkillSetsV2?.edges ?? [];
294
+ for (const edge of edges) {
295
+ const node = edge.node;
296
+ const theSkill = node.theSkill;
297
+ if (theSkill !== null) {
298
+ skills.push({
299
+ id: theSkill.id,
300
+ name: theSkill.name,
301
+ rating: node.rating,
302
+ isOptional: node.isOptional,
303
+ });
304
+ }
305
+ }
306
+ return {
307
+ ...projectListItem(entity),
308
+ descriptionMd: entity.descriptionMd,
309
+ minimumHoursPerBillingCycle: entity.minimumHoursPerBillingCycle,
310
+ isCoaching: entity.isCoaching,
311
+ isToptalProject: entity.isToptalProject,
312
+ semiMonthlyBilling: entity.semiMonthlyBilling,
313
+ positionsCount: entity.positionsCount,
314
+ jobTimeZone: entity.jobTimeZone,
315
+ client: entity.client,
316
+ skills,
317
+ languages: (entity.languages ?? []).map((lang) => ({ id: lang.id, name: lang.name })),
318
+ };
319
+ }
320
+ function projectSubscription(entity) {
321
+ if (entity === null) {
322
+ return { active: false, filters: null };
323
+ }
324
+ const filters = {};
325
+ if (entity.skills !== null)
326
+ filters.skills = entity.skills;
327
+ if (entity.keywords !== null)
328
+ filters.keywords = entity.keywords;
329
+ if (entity.excludeSkills !== null)
330
+ filters.excludeSkills = entity.excludeSkills;
331
+ if (entity.excludeKeywords !== null)
332
+ filters.excludeKeywords = entity.excludeKeywords;
333
+ if (entity.commitmentsV2 !== null)
334
+ filters.commitments = entity.commitmentsV2;
335
+ if (entity.workTypesV2 !== null)
336
+ filters.workTypes = entity.workTypesV2;
337
+ if (entity.estimatedLengths !== null)
338
+ filters.estimatedLengths = entity.estimatedLengths;
339
+ if (entity.excludeUnspecifiedBudget !== null)
340
+ filters.excludeUnspecifiedBudget = entity.excludeUnspecifiedBudget;
341
+ return { active: true, filters };
342
+ }
343
+ function formatMutationErrors(operationName, errors) {
344
+ if (errors === null || errors === undefined || errors.length === 0) {
345
+ return `${operationName}: mutation reported failure but returned no errors`;
346
+ }
347
+ return `${operationName}: ${errors
348
+ .map((e) => `${e.key ?? "(no key)"}: ${e.message ?? "(no message)"} (code: ${e.code ?? "unknown"})`)
349
+ .join("; ")}`;
350
+ }
351
+ function buildListVariables(opts, extras) {
352
+ // Pagination defaults: `page: 1, perPage: 20` map to the wire's
353
+ // 1-indexed `page: 1` (= first page). The captured `InitialJobs`
354
+ // operation hardcoded `page: 0` literally, but empirical E2E
355
+ // testing during #138 verification revealed the wire's `page` field
356
+ // is **1-indexed** — `page: 0` and `page: 1` both return the same
357
+ // first slice (the gateway treats 0 as a default-to-first alias).
358
+ // Pages 2, 3, … navigate normally. The pre-#138 hardcoded `page: 0`
359
+ // worked because of this aliasing, NOT because the wire is
360
+ // 0-indexed. The user-facing `--page` is 1-indexed and threads
361
+ // through verbatim; no subtraction.
362
+ const page = opts.page ?? DEFAULT_PAGE;
363
+ const perPage = opts.perPage ?? DEFAULT_PER_PAGE;
364
+ const variables = {
365
+ skills: opts.skills && opts.skills.length > 0 ? opts.skills : null,
366
+ keywords: opts.keywords && opts.keywords.length > 0 ? opts.keywords : null,
367
+ excludeSkills: opts.excludeSkills && opts.excludeSkills.length > 0 ? opts.excludeSkills : null,
368
+ excludeKeywords: opts.excludeKeywords && opts.excludeKeywords.length > 0 ? opts.excludeKeywords : null,
369
+ commitments: opts.commitments && opts.commitments.length > 0 ? opts.commitments : null,
370
+ workTypes: opts.workTypes && opts.workTypes.length > 0 ? opts.workTypes : null,
371
+ estimatedLengths: opts.estimatedLengths && opts.estimatedLengths.length > 0 ? opts.estimatedLengths : null,
372
+ sortTarget: opts.sortTarget ?? null,
373
+ page,
374
+ pageSize: perPage,
375
+ };
376
+ variables["saved"] = extras.saved !== undefined ? { eq: extras.saved } : null;
377
+ variables["notInterested"] = extras.notInterested !== undefined ? { eq: extras.notInterested } : null;
378
+ return variables;
379
+ }
380
+ // ---------------------------------------------------------------------
381
+ // Public surface
382
+ // ---------------------------------------------------------------------
383
+ /**
384
+ * Browse current job opportunities (default sort, paginated).
385
+ *
386
+ * Filters fold straight through to the wire (`eligibleJobs`
387
+ * arguments). Empty arrays / undefined values pass as `null`, letting
388
+ * the server apply its defaults.
389
+ *
390
+ * Pagination (#138): `opts.page` (1-indexed) and `opts.perPage` are
391
+ * forwarded to the wire's 1-indexed `eligibleJobs.page` and
392
+ * `pageSize`. Defaults: `page: 1, perPage: 20`. The wire's `page` is
393
+ * 1-indexed — see {@link buildListVariables} for the empirical
394
+ * findings from #138 E2E verification. Returns a {@link
395
+ * JobListPage} carrying `totalCount` so callers can render
396
+ * offset-style pagination metadata.
397
+ */
398
+ export async function list(token, opts = {}) {
399
+ const page = opts.page ?? DEFAULT_PAGE;
400
+ const perPage = opts.perPage ?? DEFAULT_PER_PAGE;
401
+ const data = await callGateway(token, "JobsList", JOBS_LIST_QUERY, buildListVariables(opts, {}));
402
+ if (data.viewer === null) {
403
+ throw new JobsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
404
+ }
405
+ if (data.viewer.eligibleJobs === null) {
406
+ return { items: [], totalCount: 0, page, perPage };
407
+ }
408
+ const items = (data.viewer.eligibleJobs.entities ?? []).map(projectListItem);
409
+ return { items, totalCount: data.viewer.eligibleJobs.totalCount, page, perPage };
410
+ }
411
+ /**
412
+ * Fetch a single job by id. Throws `JobsError("NOT_FOUND")` for two
413
+ * wire shapes: top-level `Record not found` GraphQL error AND a
414
+ * successful response with `viewer.job === null`.
415
+ */
416
+ export async function show(token, id) {
417
+ let data;
418
+ try {
419
+ data = await callGateway(token, "JobShow", JOB_SHOW_QUERY, { id });
420
+ }
421
+ catch (err) {
422
+ if (err instanceof JobsError && err.code === "GRAPHQL_ERROR" && NOT_FOUND_MESSAGE_PATTERN.test(err.message)) {
423
+ throw new JobsError("NOT_FOUND", `No job found with id "${id}" (or you don't have access to it).`, {
424
+ cause: err,
425
+ });
426
+ }
427
+ throw err;
428
+ }
429
+ if (data.viewer === null) {
430
+ throw new JobsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
431
+ }
432
+ if (data.viewer.job === null) {
433
+ throw new JobsError("NOT_FOUND", `No job found with id "${id}" (or you don't have access to it).`);
434
+ }
435
+ return projectJobDetail(data.viewer.job);
436
+ }
437
+ /**
438
+ * List saved jobs (the bookmark / favorites view).
439
+ *
440
+ * Implementation: `eligibleJobs` with `filter: { saved: { eq: true } }`
441
+ * — the same projection as {@link list} so the CLI can reuse the table
442
+ * renderer.
443
+ *
444
+ * Pagination (#138): accepts `opts.page` / `opts.perPage`; returns
445
+ * a {@link JobListPage} for offset-style envelope rendering.
446
+ */
447
+ export async function saved(token, opts = {}) {
448
+ const page = opts.page ?? DEFAULT_PAGE;
449
+ const perPage = opts.perPage ?? DEFAULT_PER_PAGE;
450
+ const data = await callGateway(token, "JobsList", JOBS_LIST_QUERY, buildListVariables(opts, { saved: true }));
451
+ if (data.viewer === null) {
452
+ throw new JobsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
453
+ }
454
+ if (data.viewer.eligibleJobs === null) {
455
+ return { items: [], totalCount: 0, page, perPage };
456
+ }
457
+ const items = (data.viewer.eligibleJobs.entities ?? []).map(projectListItem);
458
+ return { items, totalCount: data.viewer.eligibleJobs.totalCount, page, perPage };
459
+ }
460
+ /**
461
+ * List jobs marked as not-interested. Implementation: `eligibleJobs`
462
+ * with `filter: { notInterested: { eq: true } }`.
463
+ *
464
+ * Pagination (#138): accepts `opts.page` / `opts.perPage`; returns
465
+ * a {@link JobListPage} for offset-style envelope rendering.
466
+ */
467
+ export async function notInterestedList(token, opts = {}) {
468
+ const page = opts.page ?? DEFAULT_PAGE;
469
+ const perPage = opts.perPage ?? DEFAULT_PER_PAGE;
470
+ const data = await callGateway(token, "JobsList", JOBS_LIST_QUERY, buildListVariables(opts, { notInterested: true }));
471
+ if (data.viewer === null) {
472
+ throw new JobsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
473
+ }
474
+ if (data.viewer.eligibleJobs === null) {
475
+ return { items: [], totalCount: 0, page, perPage };
476
+ }
477
+ const items = (data.viewer.eligibleJobs.entities ?? []).map(projectListItem);
478
+ return { items, totalCount: data.viewer.eligibleJobs.totalCount, page, perPage };
479
+ }
480
+ /**
481
+ * List jobs marked as viewed.
482
+ *
483
+ * **R1 — Wire-shape gap**: the `eligibleJobs` query exposes filters
484
+ * only for `saved` and `notInterested`, not `viewed`. This function
485
+ * fetches the requested page of jobs ({@link list}) and applies a
486
+ * client-side filter on the `viewed` boolean. The result is scoped to
487
+ * the items the server returned on that page — pagination shifts the
488
+ * underlying fetch window, but the client-side filter on `viewed`
489
+ * still happens AFTER the page is returned, so the resulting list can
490
+ * be shorter than `perPage`. A wire-level filter would be the right
491
+ * long-term fix and is tracked as a follow-up.
492
+ *
493
+ * Pagination (#138): accepts `opts.page` / `opts.perPage`. The
494
+ * returned {@link JobListPage} carries the `totalCount` of the
495
+ * underlying paginated fetch (pre-filter) — `items.length` reflects
496
+ * the post-filter shape and can be smaller.
497
+ */
498
+ export async function viewedList(token, opts = {}) {
499
+ const page = await list(token, opts);
500
+ return { ...page, items: page.items.filter((it) => it.viewed === true) };
501
+ }
502
+ /**
503
+ * Mark a job as saved (bookmark). The wire mutation
504
+ * (`MarkJobAsSaved`) toggles `saved=true` and clears `notInterested=false`
505
+ * if it was set — the server's interest-status model is one-of-three
506
+ * (`saved` | `not-interested` | `cleared`).
507
+ *
508
+ * Dry-run path (issue #162): when invoked with `options.dryRun === true`,
509
+ * builds a {@link DryRunPreview} of the mutation without invoking the
510
+ * gateway transport and returns it wrapped in {@link
511
+ * JobsDryRunPreviewOutcome}. The bearer token is redacted per the
512
+ * `DryRunPreview` contract; the `jobID` variable carries the caller's
513
+ * literal id (no sibling read needed for this surface).
514
+ */
515
+ export async function save(token, id, options = {}) {
516
+ const variables = { jobID: id };
517
+ if (options.dryRun === true) {
518
+ return {
519
+ kind: "preview",
520
+ preview: buildDryRunPreview({
521
+ surface: "mobile-gateway",
522
+ authToken: token,
523
+ body: { operationName: "JobMarkSaved", query: MARK_JOB_SAVED_MUTATION, variables },
524
+ }),
525
+ };
526
+ }
527
+ const data = await callGateway(token, "JobMarkSaved", MARK_JOB_SAVED_MUTATION, variables);
528
+ return { kind: "applied", result: narrowMutation(data, "markSaved", id, "JobMarkSaved") };
529
+ }
530
+ /**
531
+ * Clear all interest-status flags on a job. The CLI exposes this as
532
+ * `jobs unsave <id>` (matching the AC) — semantically it also clears
533
+ * `notInterested` because the wire only offers one path
534
+ * (`ClearJobInterestStatus`) to clear EITHER signal. Callers wanting
535
+ * the "remove saved without affecting not-interested" semantics aren't
536
+ * supported by the wire; they would need to re-mark not-interested
537
+ * after.
538
+ *
539
+ * Delegates to {@link clearInterest} (same wire operation
540
+ * `JobClearInterest`) — the dry-run preview therefore reports
541
+ * `operationName: "JobClearInterest"`. The CLI envelope's
542
+ * surface-level `operation` field is `jobs.unsave` (kept distinct so
543
+ * users see the verb they invoked).
544
+ */
545
+ export async function unsave(token, id, options = {}) {
546
+ return clearInterest(token, id, options);
547
+ }
548
+ /**
549
+ * Mark a job as not-interested with the supplied reason. The wire
550
+ * mutation (`MarkJobAsNotInterested`) toggles `notInterested=true`
551
+ * and clears `saved=false` if it was set.
552
+ *
553
+ * `reason` is server-side `String!` — rejects empty strings with
554
+ * `code=blank, key=reason`. Caller must supply a non-empty value; the
555
+ * wire does not validate against a closed enum, so free-text is fine.
556
+ *
557
+ * Dry-run path (issue #162): when invoked with `options.dryRun === true`,
558
+ * builds a {@link DryRunPreview} of the mutation without invoking the
559
+ * gateway transport and returns it wrapped in {@link
560
+ * JobsDryRunPreviewOutcome}. The `reason` variable is preserved in the
561
+ * preview's variables payload (it carries no session-bound material) so
562
+ * callers can verify the wire-shape end-to-end.
563
+ */
564
+ export async function notInterested(token, id, opts, options = {}) {
565
+ const variables = { jobID: id, reason: opts.reason };
566
+ if (options.dryRun === true) {
567
+ return {
568
+ kind: "preview",
569
+ preview: buildDryRunPreview({
570
+ surface: "mobile-gateway",
571
+ authToken: token,
572
+ body: { operationName: "JobMarkNotInterested", query: MARK_JOB_NOT_INTERESTED_MUTATION, variables },
573
+ }),
574
+ };
575
+ }
576
+ const data = await callGateway(token, "JobMarkNotInterested", MARK_JOB_NOT_INTERESTED_MUTATION, variables);
577
+ return { kind: "applied", result: narrowMutation(data, "markNotInterested", id, "JobMarkNotInterested") };
578
+ }
579
+ /**
580
+ * Mark a job as viewed (UX-only signal — typically the UI auto-marks
581
+ * on detail-page open; this surface lets the CLI do it explicitly).
582
+ *
583
+ * Dry-run path (issue #162): when invoked with `options.dryRun === true`,
584
+ * builds a {@link DryRunPreview} of the mutation without invoking the
585
+ * gateway transport and returns it wrapped in {@link
586
+ * JobsDryRunPreviewOutcome}.
587
+ */
588
+ export async function markViewed(token, id, options = {}) {
589
+ const variables = { jobID: id };
590
+ if (options.dryRun === true) {
591
+ return {
592
+ kind: "preview",
593
+ preview: buildDryRunPreview({
594
+ surface: "mobile-gateway",
595
+ authToken: token,
596
+ body: { operationName: "JobMarkViewed", query: MARK_JOB_VIEWED_MUTATION, variables },
597
+ }),
598
+ };
599
+ }
600
+ const data = await callGateway(token, "JobMarkViewed", MARK_JOB_VIEWED_MUTATION, variables);
601
+ return { kind: "applied", result: narrowMutation(data, "markViewed", id, "JobMarkViewed") };
602
+ }
603
+ /**
604
+ * Clear the interest-status flags (both `saved` and `notInterested`)
605
+ * on a job. The wire's "undo" path for either save or not-interested.
606
+ *
607
+ * Dry-run path (issue #162): when invoked with `options.dryRun === true`,
608
+ * builds a {@link DryRunPreview} of the mutation without invoking the
609
+ * gateway transport and returns it wrapped in {@link
610
+ * JobsDryRunPreviewOutcome}.
611
+ */
612
+ export async function clearInterest(token, id, options = {}) {
613
+ const variables = { jobID: id };
614
+ if (options.dryRun === true) {
615
+ return {
616
+ kind: "preview",
617
+ preview: buildDryRunPreview({
618
+ surface: "mobile-gateway",
619
+ authToken: token,
620
+ body: { operationName: "JobClearInterest", query: CLEAR_JOB_INTEREST_MUTATION, variables },
621
+ }),
622
+ };
623
+ }
624
+ const data = await callGateway(token, "JobClearInterest", CLEAR_JOB_INTEREST_MUTATION, variables);
625
+ return { kind: "applied", result: narrowMutation(data, "clearInterestStatus", id, "JobClearInterest") };
626
+ }
627
+ function narrowMutation(data, field, id, operationName) {
628
+ if (data.job === null) {
629
+ throw new JobsError("NOT_FOUND", `No job found with id "${id}" (or you don't have access to it).`);
630
+ }
631
+ const result = data.job[field];
632
+ if (result === null || result === undefined) {
633
+ throw new JobsError("UNKNOWN", `${operationName} returned a null payload for field "${field}".`);
634
+ }
635
+ if (!result.success) {
636
+ throw new JobsError("MUTATION_ERROR", formatMutationErrors(operationName, result.errors));
637
+ }
638
+ if (result.job === null) {
639
+ throw new JobsError("UNKNOWN", `${operationName} returned success but the \`job\` payload was null.`);
640
+ }
641
+ return {
642
+ id: result.job.id,
643
+ saved: result.job.saved,
644
+ notInterested: result.job.notInterested,
645
+ viewed: result.job.viewed,
646
+ };
647
+ }
648
+ /**
649
+ * Show the current job-search subscription state. Returns
650
+ * `{ active: false, filters: null }` when no subscription is active.
651
+ *
652
+ * **R2**: the wire models a single subscription per viewer — there is
653
+ * no list. The CLI's `search list` maps this to a 0-or-1 envelope.
654
+ */
655
+ export async function searchSubscriptionShow(token) {
656
+ const data = await callGateway(token, "JobSearchSubscriptionShow", JOB_SEARCH_SUBSCRIPTION_QUERY, {});
657
+ if (data.viewer === null) {
658
+ throw new JobsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
659
+ }
660
+ return projectSubscription(data.viewer.searchSubscription);
661
+ }
662
+ /**
663
+ * Start the job-search subscription with the supplied filters. If a
664
+ * subscription is already active, the wire's `start` mutation replaces
665
+ * it (the server does NOT error on "already subscribed").
666
+ *
667
+ * Returns the post-mutation subscription state.
668
+ *
669
+ * Dry-run path (issue #162): when invoked with `options.dryRun === true`,
670
+ * builds a {@link DryRunPreview} of the mutation without invoking the
671
+ * gateway transport and returns it wrapped in {@link
672
+ * JobsDryRunPreviewOutcome}. The filters payload is normalised
673
+ * identically to the apply path so the preview's `variables` reflect
674
+ * the exact wire shape that WOULD have been sent.
675
+ */
676
+ export async function searchSubscriptionSave(token, filters, options = {}) {
677
+ const variables = {
678
+ skills: filters.skills && filters.skills.length > 0 ? filters.skills : null,
679
+ keywords: filters.keywords && filters.keywords.length > 0 ? filters.keywords : null,
680
+ excludeSkills: filters.excludeSkills && filters.excludeSkills.length > 0 ? filters.excludeSkills : null,
681
+ excludeKeywords: filters.excludeKeywords && filters.excludeKeywords.length > 0 ? filters.excludeKeywords : null,
682
+ commitments: filters.commitments && filters.commitments.length > 0 ? filters.commitments : null,
683
+ workTypes: filters.workTypes && filters.workTypes.length > 0 ? filters.workTypes : null,
684
+ estimatedLengths: filters.estimatedLengths && filters.estimatedLengths.length > 0 ? filters.estimatedLengths : null,
685
+ excludeUnspecifiedBudget: filters.excludeUnspecifiedBudget ?? null,
686
+ };
687
+ if (options.dryRun === true) {
688
+ return {
689
+ kind: "preview",
690
+ preview: buildDryRunPreview({
691
+ surface: "mobile-gateway",
692
+ authToken: token,
693
+ body: { operationName: "JobSearchSubscriptionStart", query: START_JOB_SUBSCRIPTION_MUTATION, variables },
694
+ }),
695
+ };
696
+ }
697
+ const data = await callGateway(token, "JobSearchSubscriptionStart", START_JOB_SUBSCRIPTION_MUTATION, variables);
698
+ if (data.searchSubscription === null) {
699
+ throw new JobsError("UNKNOWN", "JobSearchSubscriptionStart returned a null `searchSubscription` payload.");
700
+ }
701
+ const result = data.searchSubscription.start;
702
+ if (result === null) {
703
+ throw new JobsError("UNKNOWN", "JobSearchSubscriptionStart returned a null `start` payload.");
704
+ }
705
+ if (!result.success) {
706
+ throw new JobsError("MUTATION_ERROR", formatMutationErrors("JobSearchSubscriptionStart", result.errors));
707
+ }
708
+ return { kind: "applied", result: projectSubscription(result.viewer?.searchSubscription ?? null) };
709
+ }
710
+ /**
711
+ * Terminate the active job-search subscription. The wire's `terminate`
712
+ * mutation is idempotent — terminating a non-active subscription
713
+ * returns `success: true` with no errors.
714
+ *
715
+ * Returns `{ terminated: true }` on success. The post-terminate
716
+ * subscription state is implicit (`active: false`) and is not re-
717
+ * fetched here.
718
+ *
719
+ * Dry-run path (issue #162): when invoked with `options.dryRun === true`,
720
+ * builds a {@link DryRunPreview} of the mutation without invoking the
721
+ * gateway transport and returns it wrapped in {@link
722
+ * JobsDryRunPreviewOutcome}. The variables payload is `{}` (the wire
723
+ * `terminate` mutation takes no variables).
724
+ */
725
+ export async function searchSubscriptionRemove(token, options = {}) {
726
+ if (options.dryRun === true) {
727
+ return {
728
+ kind: "preview",
729
+ preview: buildDryRunPreview({
730
+ surface: "mobile-gateway",
731
+ authToken: token,
732
+ body: {
733
+ operationName: "JobSearchSubscriptionTerminate",
734
+ query: TERMINATE_JOB_SUBSCRIPTION_MUTATION,
735
+ variables: {},
736
+ },
737
+ }),
738
+ };
739
+ }
740
+ const data = await callGateway(token, "JobSearchSubscriptionTerminate", TERMINATE_JOB_SUBSCRIPTION_MUTATION, {});
741
+ if (data.searchSubscription === null) {
742
+ throw new JobsError("UNKNOWN", "JobSearchSubscriptionTerminate returned a null `searchSubscription` payload.");
743
+ }
744
+ const result = data.searchSubscription.terminate;
745
+ if (result === null) {
746
+ throw new JobsError("UNKNOWN", "JobSearchSubscriptionTerminate returned a null `terminate` payload.");
747
+ }
748
+ if (!result.success) {
749
+ throw new JobsError("MUTATION_ERROR", formatMutationErrors("JobSearchSubscriptionTerminate", result.errors));
750
+ }
751
+ return { kind: "applied", result: { terminated: true } };
752
+ }
753
+ //# sourceMappingURL=index.js.map