@expandai/sdk 0.17.1 → 0.18.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/Playground.ts CHANGED
@@ -3,12 +3,9 @@
3
3
  // Canonical public snapshot URLs (all public, no auth, no TTL):
4
4
  //
5
5
  // /s/<snapshotId> — whole snapshot
6
- // /s/<snapshotId>/playground#focus=<id> highlight a markdown/appendix block by node id
7
- // /s/<snapshotId>?id=<evidenceId> — open extracted State JSON evidence
6
+ // /s/<snapshotId>?id=<evidenceId> open any public evidence target
8
7
  //
9
- // A citation maps a search snippet (`source` + `location`) to the view that audits it.
10
-
11
- import { Match } from 'effect'
8
+ // A citation maps a search snippet location to the view that audits it.
12
9
 
13
10
  export const DEFAULT_PLAYGROUND_HOST = 'https://expand.land'
14
11
 
@@ -16,7 +13,12 @@ export type CitationSource = 'markdown' | 'appendix' | 'statejson'
16
13
 
17
14
  /** Snippet location subset needed to build a deep link. Tolerates `null` and `undefined`. */
18
15
  export interface CitationLocation {
16
+ readonly evidenceId?: number | null
17
+ /** @deprecated Use evidenceId. */
18
+ readonly markdownBlockId?: string | null
19
+ /** @deprecated Use evidenceId for public citations. */
19
20
  readonly nodeIds?: ReadonlyArray<number> | null
21
+ /** @deprecated Use evidenceId. */
20
22
  readonly stateJsonSourceId?: number | null
21
23
  }
22
24
 
@@ -24,6 +26,18 @@ function normalizeHost(host: string): string {
24
26
  return host.replace(/\/+$/, '')
25
27
  }
26
28
 
29
+ function publicEvidenceId(value: number | null | undefined): number | undefined {
30
+ return value !== null && value !== undefined && Number.isInteger(value) && value > 0 && value <= 2_147_483_647
31
+ ? value
32
+ : undefined
33
+ }
34
+
35
+ function legacyArchiveId(value: number | null | undefined): number | undefined {
36
+ return value !== null && value !== undefined && Number.isInteger(value) && value >= 0 && value <= 0xffffffff
37
+ ? value
38
+ : undefined
39
+ }
40
+
27
41
  /** Whole-snapshot URL: `${host}/s/${snapshotId}`. */
28
42
  export function playgroundBase(snapshotId: string, host: string = DEFAULT_PLAYGROUND_HOST): string {
29
43
  return `${normalizeHost(host)}/s/${encodeURIComponent(snapshotId)}`
@@ -51,26 +65,46 @@ export function resolvePlaygroundHost(metaPlayground: string | null | undefined,
51
65
  }
52
66
 
53
67
  /**
54
- * Deep link auditing a single snippet:
55
- * - `statejson` with a source id → `?id=<stateJsonSourceId>` (opens the JSON evidence object).
56
- * - `markdown`/`appendix` with a node id → `/playground#focus=<nodeIds[0]>` (highlights the block).
57
- * - otherwise → the whole-snapshot base URL.
68
+ * Deep link auditing a single snippet by its canonical public Evidence ID.
58
69
  */
70
+ export function citationUrl(snapshotId: string, location: CitationLocation | null | undefined, host?: string): string
71
+ /** @deprecated Pass the location as the second argument; source is no longer needed. */
59
72
  export function citationUrl(
60
73
  snapshotId: string,
61
74
  source: CitationSource,
62
75
  location: CitationLocation | null | undefined,
63
- host: string = DEFAULT_PLAYGROUND_HOST,
76
+ host?: string,
77
+ ): string
78
+ export function citationUrl(
79
+ snapshotId: string,
80
+ sourceOrLocation: CitationSource | CitationLocation | null | undefined,
81
+ locationOrHost?: CitationLocation | string | null,
82
+ legacyHost: string = DEFAULT_PLAYGROUND_HOST,
64
83
  ): string {
84
+ const legacySignature = typeof sourceOrLocation === 'string'
85
+ const source = legacySignature ? sourceOrLocation : undefined
86
+ const location = legacySignature
87
+ ? typeof locationOrHost === 'string'
88
+ ? undefined
89
+ : locationOrHost
90
+ : sourceOrLocation
91
+ const host = legacySignature
92
+ ? legacyHost
93
+ : typeof locationOrHost === 'string'
94
+ ? locationOrHost
95
+ : DEFAULT_PLAYGROUND_HOST
65
96
  const base = playgroundBase(snapshotId, host)
66
- return Match.value(source).pipe(
67
- Match.when('statejson', () => {
68
- const sourceId = location?.stateJsonSourceId
69
- return sourceId != null ? `${base}?id=${sourceId}` : base
70
- }),
71
- Match.orElse(() => {
72
- const nodeId = location?.nodeIds?.[0]
73
- return nodeId != null ? `${base}/playground#focus=${nodeId}` : base
74
- }),
75
- )
97
+ const evidenceId = publicEvidenceId(location?.evidenceId)
98
+ if (evidenceId !== undefined) {
99
+ return `${base}?id=${evidenceId}`
100
+ }
101
+ if (source === 'statejson') {
102
+ const legacyId = legacyArchiveId(location?.stateJsonSourceId)
103
+ return legacyId === undefined ? base : `${base}?id=${legacyId}`
104
+ }
105
+ if (source === 'markdown' || source === 'appendix') {
106
+ const nodeId = legacyArchiveId(location?.nodeIds?.[0])
107
+ return nodeId === undefined ? base : `${base}/playground#focus=${nodeId}`
108
+ }
109
+ return base
76
110
  }
package/src/Service.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { HttpClient, HttpClientRequest, HttpClientResponse } from '@effect/platform'
2
2
  import { NodeHttpClient } from '@effect/platform-node'
3
- import { Context, Duration, Effect, flow, Layer, Schedule, Schema } from 'effect'
3
+ import { Context, Duration, Effect, flow, Layer, Option, Random, Schedule, Schema } from 'effect'
4
4
  import { ExpandSdkError } from './Error.js'
5
5
  import {
6
6
  type FetchObjectModeResultWithJson,
@@ -31,29 +31,28 @@ export class ExpandServiceOptions extends Schema.Class<ExpandServiceOptions>('Ex
31
31
  }),
32
32
  /**
33
33
  * Total wall-clock budget in milliseconds for the operation, including retries and backoff.
34
- * Each attempt gets `timeoutMs / (maxRetries + 1)`. Defaults to 60_000.
34
+ * A successful attempt may use the remaining operation budget. Defaults to 60_000.
35
35
  */
36
36
  timeoutMs: Schema.optional(Schema.Number).annotations({
37
37
  description:
38
38
  'Total wall-clock budget in milliseconds for the operation, including retries and backoff. ' +
39
- 'Each attempt gets `timeoutMs / (maxRetries + 1)`. Defaults to 60_000.',
39
+ 'A successful attempt may use the remaining operation budget. Defaults to 60_000.',
40
40
  }),
41
41
  /**
42
42
  * Number of retry attempts for retryable failures (timeouts, connection errors, 408/409/429/5xx).
43
- * Defaults to 2. Note: `batched()` defaults to 0 regardless of this setting; pass `{ maxRetries: N }`
44
- * per-call to opt in.
43
+ * Defaults to 2.
45
44
  */
46
45
  maxRetries: Schema.optional(Schema.Number).annotations({
47
46
  description:
48
47
  'Number of retry attempts for retryable failures (timeouts, connection errors, 408/409/429/5xx). ' +
49
- 'Defaults to 2. Note: `batched()` defaults to 0 regardless of this setting; pass `{ maxRetries: N }` per-call to opt in.',
48
+ 'Defaults to 2.',
50
49
  }),
51
50
  },
52
51
  { description: 'Options for constructing an `ExpandService` layer or `ExpandClient` instance.' },
53
52
  ) {}
54
53
 
55
54
  /**
56
- * Per-request overrides accepted by `ExpandService.fetch`/`batched`/`getBatched` (and, transitively,
55
+ * Per-request overrides accepted by `ExpandService.fetch`/`batched`/`getBatched`/`cancelBatched` (and, transitively,
57
56
  * by `ExpandClient`'s methods).
58
57
  */
59
58
  export class RequestOptions extends Schema.Class<RequestOptions>('RequestOptions')(
@@ -82,8 +81,12 @@ export type FetchSearchParams = typeof Generated.FetchFetchSearchRequest.Encoded
82
81
  export type FetchSearchResponse = FetchSnapshotSearchResultWithJson
83
82
  export type BatchedParams = typeof Generated.FetchBatchedRequest.Encoded
84
83
  export type BatchedResponse = typeof Generated.BatchedRun.Type
84
+ export interface BatchedRequestOptions extends RequestOptions {
85
+ readonly idempotencyKey?: string
86
+ }
85
87
  export type GetBatchedParams = typeof Generated.FetchGetBatchedParams.Encoded
86
88
  export type GetBatchedResponse = typeof Generated.BatchedStatus.Type
89
+ export type CancelBatchedResponse = typeof Generated.BatchedCancellation.Type
87
90
 
88
91
  class ServiceConfig extends Schema.Class<ServiceConfig>('ExpandServiceConfig')({
89
92
  apiKey: Schema.NonEmptyString,
@@ -175,20 +178,14 @@ export class ExpandService extends Effect.Service<ExpandService>()('@expandai/sd
175
178
  ) {
176
179
  const totalTimeoutMs = requestOptions.timeoutMs ?? config.timeoutMs
177
180
  const maxRetries = requestOptions.maxRetries ?? config.maxRetries
178
- // `timeoutMs` is the wall-clock budget for the whole operation. Split it across attempts so the
179
- // sum of per-attempt budgets equals `timeoutMs`; the outer Effect.timeout enforces the hard cap
180
- // including retry backoff. Pipe ordering matters: per-attempt timeout must wrap the operation
181
- // (so retry can catch its TimeoutException), and total timeout must wrap retry (so it caps
182
- // backoff between attempts). Floor with a 1ms minimum so degenerate inputs don't produce a
183
- // zero-millisecond budget that fires before any work happens.
184
- const perAttemptTimeoutMs = Math.max(1, Math.floor(totalTimeoutMs / (maxRetries + 1)))
181
+ // `timeoutMs` is one wall-clock budget for the operation, including retries and backoff. A slow
182
+ // valid capture may use most of that budget; only failures that arrive before it expires can retry.
185
183
  const retrySchedule = Schedule.exponential(Duration.millis(250)).pipe(
186
184
  Schedule.jittered,
187
185
  Schedule.compose(Schedule.recurs(maxRetries)),
188
186
  Schedule.whileInput((error: unknown) => isRetryable(error, totalTimeoutMs)),
189
187
  )
190
188
  return yield* operation.pipe(
191
- Effect.timeout(Duration.millis(perAttemptTimeoutMs)),
192
189
  Effect.retry(retrySchedule),
193
190
  Effect.timeout(Duration.millis(totalTimeoutMs)),
194
191
  Effect.mapError((error) => toExpandError(error, totalTimeoutMs)),
@@ -226,15 +223,26 @@ export class ExpandService extends Effect.Service<ExpandService>()('@expandai/sd
226
223
 
227
224
  const batched = Effect.fn('ExpandService.batched')(function* (
228
225
  params: BatchedParams,
229
- requestOptions: RequestOptions = {},
226
+ requestOptions: BatchedRequestOptions = {},
230
227
  ) {
231
- // Batched POST kicks off server-side work and the API has no idempotency-key support; a retry on
232
- // a request that succeeded server-side but failed client-side (timeout, dropped connection)
233
- // would create a duplicate batched run. Default to no retries; callers can opt in explicitly.
234
- // Destructure-with-default also handles `{ maxRetries: undefined }` correctly — falls back to 0
235
- // rather than the service-level default.
236
- const { maxRetries = 0, ...rest } = requestOptions
237
- return yield* withRetries(generated.fetchBatched(params), { maxRetries, ...rest })
228
+ const { idempotencyKey: providedIdempotencyKey, ...retryOptions } = requestOptions
229
+ const idempotencyKey = yield* Option.fromNullable(providedIdempotencyKey).pipe(
230
+ Option.match({
231
+ onSome: Effect.succeed,
232
+ onNone: () =>
233
+ Effect.all([Random.nextInt, Random.nextInt, Random.nextInt]).pipe(
234
+ Effect.map(
235
+ ([first, second, third]) =>
236
+ `sdk-${Math.abs(first).toString(36)}-${Math.abs(second).toString(36)}-${Math.abs(third).toString(36)}`,
237
+ ),
238
+ ),
239
+ }),
240
+ )
241
+ const generatedRequest: Parameters<typeof generated.fetchBatched>[0] = {
242
+ params: { 'x-idempotency-key': idempotencyKey },
243
+ payload: params,
244
+ }
245
+ return yield* withRetries(generated.fetchBatched(generatedRequest), retryOptions)
238
246
  })
239
247
 
240
248
  const getBatched = Effect.fn('ExpandService.getBatched')(function* (
@@ -247,7 +255,14 @@ export class ExpandService extends Effect.Service<ExpandService>()('@expandai/sd
247
255
  return yield* withRetries(generated.fetchGetBatched(id, params), { timeoutMs, maxRetries })
248
256
  })
249
257
 
250
- return { fetch, fetchJson, fetchSearch, batched, getBatched } as const
258
+ const cancelBatched = Effect.fn('ExpandService.cancelBatched')(function* (
259
+ id: string,
260
+ requestOptions: RequestOptions = {},
261
+ ) {
262
+ return yield* withRetries(generated.fetchCancelBatched(id), requestOptions)
263
+ })
264
+
265
+ return { fetch, fetchJson, fetchSearch, batched, getBatched, cancelBatched } as const
251
266
  }),
252
267
  }) {
253
268
  /**
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export type {
2
2
  BatchedParams,
3
+ BatchedRequestOptions,
3
4
  BatchedResponse,
5
+ CancelBatchedResponse,
4
6
  ExpandClientOptions,
5
7
  FetchJsonParams,
6
8
  FetchJsonRequestOptions,