@gscdump/cloudflare 3.5.0 → 3.6.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/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @gscdump/cloudflare
2
+
3
+ Cloudflare helpers for server-tail queries and concurrent request deduplication.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @gscdump/cloudflare
9
+ ```
10
+
11
+ The package has two public subpaths and no root export.
12
+
13
+ | Subpath | Purpose |
14
+ | --- | --- |
15
+ | `@gscdump/cloudflare/server-tail` | Route queries through R2 SQL or a DuckDB service |
16
+ | `@gscdump/cloudflare/inflight-dedupe` | Share pending requests with identical keys |
17
+
18
+ ## Deduplicate pending requests
19
+
20
+ ```ts
21
+ import { createInflightDedupe, getHostedR2QueryKey } from '@gscdump/cloudflare/inflight-dedupe'
22
+
23
+ const requests = createInflightDedupe<string[]>()
24
+ const key = getHostedR2QueryKey({
25
+ userId: 'u_01',
26
+ siteId: 's_01',
27
+ state: { dimensions: ['page'] },
28
+ })
29
+ const rows = await requests.dedupe(key, async () => ['https://example.com/docs'])
30
+ console.log(rows)
31
+ ```
32
+
33
+ Concurrent calls with the same key share one promise.
34
+ After it settles, the next call runs again.
35
+ Include the authenticated user, Site, query state, and comparison inputs in the key.
36
+
37
+ ## Server-tail queries
38
+
39
+ `createServerTailDispatcher` selects a configured R2 SQL or DuckDB executor.
40
+ The subpath exports the executor factories, transport helpers, SQL compilation, and typed routing errors.
41
+ Your host supplies credentials, transport, authorization, and the DuckDB service binding.
42
+
43
+ For storage contracts, see [`@gscdump/engine`](../engine/README.md).
44
+ For hosted HTTP integrations, use [`@gscdump/sdk`](../sdk/README.md).
45
+
46
+ ## License
47
+
48
+ [MIT](../../LICENSE)
@@ -1,15 +1,14 @@
1
- interface InflightDedupe<T> {
1
+ export interface InflightDedupe<T> {
2
2
  dedupe: (key: string, run: () => Promise<T>) => Promise<T>;
3
3
  has: (key: string) => boolean;
4
4
  clear: () => void;
5
5
  }
6
- declare function createInflightDedupe<T>(): InflightDedupe<T>;
7
- interface HostedR2QueryKeyInput {
6
+ export declare function createInflightDedupe<T>(): InflightDedupe<T>;
7
+ export interface HostedR2QueryKeyInput {
8
8
  userId: string | number;
9
9
  siteId: string;
10
10
  state: unknown;
11
11
  comparison?: unknown;
12
12
  comparisonFilter?: string;
13
13
  }
14
- declare function getHostedR2QueryKey(input: HostedR2QueryKeyInput): string;
15
- export { HostedR2QueryKeyInput, InflightDedupe, createInflightDedupe, getHostedR2QueryKey };
14
+ export declare function getHostedR2QueryKey(input: HostedR2QueryKeyInput): string;
@@ -1,14 +1,14 @@
1
1
  import { ArchetypeQuery } from "@gscdump/contracts/archetypes";
2
- declare const TABLE_PLACEHOLDER = "{{TABLE}}";
3
- type ArchetypeFactTable = 'pages' | 'queries' | 'countries' | 'page_queries' | 'dates';
4
- interface ArchetypeSqlPlan {
2
+ export declare const TABLE_PLACEHOLDER = "{{TABLE}}";
3
+ export type ArchetypeFactTable = 'pages' | 'queries' | 'countries' | 'page_queries' | 'dates';
4
+ export interface ArchetypeSqlPlan {
5
5
  sql: string;
6
6
  params: unknown[];
7
7
  table: ArchetypeFactTable;
8
8
  }
9
- type PartitionPredicateMode = 'bare' | 'r2-sql-concat';
10
- type PartitionKeyEncoding = 'int' | 'string';
11
- interface BuildArchetypeSqlOptions {
9
+ export type PartitionPredicateMode = 'bare' | 'r2-sql-concat';
10
+ export type PartitionKeyEncoding = 'int' | 'string';
11
+ export interface BuildArchetypeSqlOptions {
12
12
  /**
13
13
  * Set by the DuckDB file-list executor, which reads raw Iceberg parquet
14
14
  * directly via `read_parquet([...])`, bypassing the catalog metadata layer
@@ -34,5 +34,4 @@ interface BuildArchetypeSqlOptions {
34
34
  /** Preferred input for new callers. Defaults to `'int'`. */
35
35
  partitionKeyEncoding?: PartitionKeyEncoding;
36
36
  }
37
- declare function buildArchetypeSql(query: ArchetypeQuery, opts?: BuildArchetypeSqlOptions): ArchetypeSqlPlan;
38
- export { ArchetypeFactTable, ArchetypeSqlPlan, BuildArchetypeSqlOptions, PartitionKeyEncoding, PartitionPredicateMode, TABLE_PLACEHOLDER, buildArchetypeSql };
37
+ export declare function buildArchetypeSql(query: ArchetypeQuery, opts?: BuildArchetypeSqlOptions): ArchetypeSqlPlan;
@@ -4,13 +4,13 @@ import { ArchetypeQuery, ArchetypeResult, ArchetypeResultRow } from "@gscdump/co
4
4
  import { Result } from "gscdump/result";
5
5
  import { ServerTailDirective } from "@gscdump/contracts";
6
6
  /** The two engines the server tail can route to. */
7
- type ServerTailEngine = 'r2-sql' | 'duckdb';
7
+ export type ServerTailEngine = 'r2-sql' | 'duckdb';
8
8
  /** Executors the dispatcher routes between. */
9
- interface ServerTailDispatcherConfig {
9
+ export interface ServerTailDispatcherConfig {
10
10
  r2Sql: R2SqlClient;
11
11
  duckdb: DuckDbIcebergExecutor;
12
12
  }
13
- declare class ServerTailRoutingError extends Error {
13
+ export declare class ServerTailRoutingError extends Error {
14
14
  name: string;
15
15
  }
16
16
  /**
@@ -19,7 +19,7 @@ declare class ServerTailRoutingError extends Error {
19
19
  * (the one caller-actionable routing failure — the consumer must route that
20
20
  * query through the cloud endpoints, not the server tail). Pure — no I/O.
21
21
  */
22
- declare function resolveServerTailEngineResult(query: ArchetypeQuery): Result<ServerTailEngine, ServerTailRoutingError>;
22
+ export declare function resolveServerTailEngineResult(query: ArchetypeQuery): Result<ServerTailEngine, ServerTailRoutingError>;
23
23
  /**
24
24
  * Decide which engine answers an archetype query. Pure — no I/O. Exposed so
25
25
  * the file-resolution endpoint can compute the `ServerTailDirective.engine`
@@ -27,9 +27,9 @@ declare function resolveServerTailEngineResult(query: ArchetypeQuery): Result<Se
27
27
  * `ServerTailRoutingError` for a `cloud-only` archetype; see
28
28
  * {@link resolveServerTailEngineResult} for the errors-as-values core.
29
29
  */
30
- declare function resolveServerTailEngine(query: ArchetypeQuery): ServerTailEngine;
30
+ export declare function resolveServerTailEngine(query: ArchetypeQuery): ServerTailEngine;
31
31
  /** A configured server-tail dispatcher. */
32
- interface ServerTailDispatcher {
32
+ export interface ServerTailDispatcher {
33
33
  /** Decide the engine for a query without running it. */
34
34
  route: (query: ArchetypeQuery) => ServerTailEngine;
35
35
  /**
@@ -43,5 +43,4 @@ interface ServerTailDispatcher {
43
43
  * Create the server-tail dispatcher. Holds an R2 SQL client and a DuckDB
44
44
  * executor and routes every `ArchetypeQuery` to one of them.
45
45
  */
46
- declare function createServerTailDispatcher(config: ServerTailDispatcherConfig): ServerTailDispatcher;
47
- export { ServerTailDispatcher, ServerTailDispatcherConfig, ServerTailEngine, ServerTailRoutingError, createServerTailDispatcher, resolveServerTailEngine, resolveServerTailEngineResult };
46
+ export declare function createServerTailDispatcher(config: ServerTailDispatcherConfig): ServerTailDispatcher;
@@ -2,12 +2,12 @@ import { ArchetypeSqlPlan } from "./archetype-sql.mjs";
2
2
  import { ArchetypeQuery } from "@gscdump/contracts/archetypes";
3
3
  import { Result } from "gscdump/result";
4
4
  /** Row returned by the DuckDB sibling. */
5
- type DuckDbIcebergRow = Record<string, string | number | null>;
5
+ export type DuckDbIcebergRow = Record<string, string | number | null>;
6
6
  /**
7
7
  * The minimal `DUCKDB_SVC` shape this executor needs. Any binding with
8
8
  * `runSQL` satisfies it.
9
9
  */
10
- interface DuckDbSvc {
10
+ export interface DuckDbSvc {
11
11
  runSQL: (args: {
12
12
  sql: string;
13
13
  deadlineAt?: number;
@@ -17,7 +17,7 @@ interface DuckDbSvc {
17
17
  }>;
18
18
  }
19
19
  /** Configuration for the DuckDB-over-Iceberg executor. */
20
- interface DuckDbIcebergExecutorConfig {
20
+ export interface DuckDbIcebergExecutorConfig {
21
21
  /** The DuckDB service binding (the sibling Worker RPC). */
22
22
  svc: DuckDbSvc;
23
23
  /**
@@ -38,16 +38,16 @@ interface DuckDbIcebergExecutorConfig {
38
38
  timeoutMs?: number;
39
39
  }
40
40
  /** Result of a DuckDB-over-Iceberg query. */
41
- interface DuckDbIcebergResult {
41
+ export interface DuckDbIcebergResult {
42
42
  rows: DuckDbIcebergRow[];
43
43
  /** The exact SQL sent to the sibling. */
44
44
  sql: string;
45
45
  queryMs: number;
46
46
  }
47
- declare class DuckDbIcebergError extends Error {
47
+ export declare class DuckDbIcebergError extends Error {
48
48
  name: string;
49
49
  }
50
- declare class DuckDbIcebergTimeoutError extends Error {
50
+ export declare class DuckDbIcebergTimeoutError extends Error {
51
51
  name: string;
52
52
  constructor(timeoutMs: number);
53
53
  }
@@ -60,9 +60,9 @@ declare class DuckDbIcebergTimeoutError extends Error {
60
60
  * throwing wrappers preserve the identity/message tests assert
61
61
  * (`rejects.toThrow(/OOM in sibling/)`, `rejects.toThrow(DuckDbIcebergError)`).
62
62
  */
63
- type DuckDbIcebergQueryError = DuckDbIcebergError | DuckDbIcebergTimeoutError;
63
+ export type DuckDbIcebergQueryError = DuckDbIcebergError | DuckDbIcebergTimeoutError;
64
64
  /** A configured DuckDB-over-Iceberg executor. */
65
- interface DuckDbIcebergExecutor {
65
+ export interface DuckDbIcebergExecutor {
66
66
  /** Run a raw SQL string with `{{TABLE_<name>}}` placeholders resolved. */
67
67
  runSql: (sql: string, params?: readonly unknown[]) => Promise<DuckDbIcebergResult>;
68
68
  /** Run a dialect-neutral plan: resolve `{{TABLE}}`, bind params, send. */
@@ -83,5 +83,4 @@ interface DuckDbIcebergExecutor {
83
83
  /**
84
84
  * Create a DuckDB-over-Iceberg-files executor.
85
85
  */
86
- declare function createDuckDbIcebergExecutor(config: DuckDbIcebergExecutorConfig): DuckDbIcebergExecutor;
87
- export { DuckDbIcebergError, DuckDbIcebergExecutor, DuckDbIcebergExecutorConfig, DuckDbIcebergQueryError, DuckDbIcebergResult, DuckDbIcebergRow, DuckDbIcebergTimeoutError, DuckDbSvc, createDuckDbIcebergExecutor };
86
+ export declare function createDuckDbIcebergExecutor(config: DuckDbIcebergExecutorConfig): DuckDbIcebergExecutor;
@@ -2,7 +2,7 @@ import { ArchetypeSqlPlan, PartitionKeyEncoding } from "./archetype-sql.mjs";
2
2
  import { ArchetypeQuery } from "@gscdump/contracts/archetypes";
3
3
  import { Result } from "gscdump/result";
4
4
  /** Configuration for an R2 SQL client. */
5
- interface R2SqlClientConfig {
5
+ export interface R2SqlClientConfig {
6
6
  /** Cloudflare account id. */
7
7
  accountId: string;
8
8
  /** R2 bucket backing the Iceberg catalog — R2 SQL addresses the catalog by bucket. */
@@ -33,21 +33,21 @@ interface R2SqlClientConfig {
33
33
  partitionSiteId?: (siteId: string) => string | number;
34
34
  }
35
35
  /** A row as returned by R2 SQL — flat dimension + metric values. */
36
- type R2SqlRow = Record<string, string | number | null>;
36
+ export type R2SqlRow = Record<string, string | number | null>;
37
37
  /** Result of an R2 SQL query. */
38
- interface R2SqlResult {
38
+ export interface R2SqlResult {
39
39
  rows: R2SqlRow[];
40
40
  /** The exact SQL sent (params already inlined). For diagnostics. */
41
41
  sql: string;
42
42
  /** Wall-clock duration of the HTTP round-trip. */
43
43
  queryMs: number;
44
44
  }
45
- declare class R2SqlError extends Error {
45
+ export declare class R2SqlError extends Error {
46
46
  readonly status?: number | undefined;
47
47
  name: string;
48
48
  constructor(message: string, status?: number | undefined);
49
49
  }
50
- declare class R2SqlTimeoutError extends Error {
50
+ export declare class R2SqlTimeoutError extends Error {
51
51
  name: string;
52
52
  constructor(timeoutMs: number);
53
53
  }
@@ -62,15 +62,15 @@ declare class R2SqlTimeoutError extends Error {
62
62
  * the client malformed params (`escapeSqlValue` / `inlineParams`) — are NOT
63
63
  * modelled here; they keep throwing `R2SqlError` synchronously.
64
64
  */
65
- type R2SqlQueryError = R2SqlError | R2SqlTimeoutError;
65
+ export type R2SqlQueryError = R2SqlError | R2SqlTimeoutError;
66
66
  /**
67
67
  * Inline a plan's `?`-bound params into its SQL, in order. R2 SQL accepts only
68
68
  * a literal query string. Quote-aware so a `?` inside a string literal is not
69
69
  * mistaken for a placeholder.
70
70
  */
71
- declare function inlineParams(sql: string, params: readonly unknown[]): string;
71
+ export declare function inlineParams(sql: string, params: readonly unknown[]): string;
72
72
  /** A configured R2 SQL client. */
73
- interface R2SqlClient {
73
+ export interface R2SqlClient {
74
74
  /** Run a raw SQL string (table reference already resolved). */
75
75
  query: (sql: string) => Promise<R2SqlResult>;
76
76
  /**
@@ -91,5 +91,4 @@ interface R2SqlClient {
91
91
  * Create an R2 SQL client. The endpoint requires a real CF token in
92
92
  * production; tests inject `fetchImpl` returning a recorded envelope.
93
93
  */
94
- declare function createR2SqlClient(config: R2SqlClientConfig): R2SqlClient;
95
- export { R2SqlClient, R2SqlClientConfig, R2SqlError, R2SqlQueryError, R2SqlResult, R2SqlRow, R2SqlTimeoutError, createR2SqlClient, inlineParams };
94
+ export declare function createR2SqlClient(config: R2SqlClientConfig): R2SqlClient;
@@ -1,6 +1,6 @@
1
- type R2SqlTransportRow = Record<string, unknown>;
2
- type R2SqlTransportMetrics = Record<string, number | undefined>;
3
- type R2SqlTransportResult = {
1
+ export type R2SqlTransportRow = Record<string, unknown>;
2
+ export type R2SqlTransportMetrics = Record<string, number | undefined>;
3
+ export type R2SqlTransportResult = {
4
4
  _tag: 'ok';
5
5
  rows: R2SqlTransportRow[];
6
6
  metrics: R2SqlTransportMetrics | null;
@@ -17,7 +17,7 @@ type R2SqlTransportResult = {
17
17
  body?: string;
18
18
  retryAfterMs?: number;
19
19
  };
20
- interface R2SqlTransportConfig {
20
+ export interface R2SqlTransportConfig {
21
21
  accountId: string;
22
22
  bucket: string;
23
23
  token: string;
@@ -27,9 +27,8 @@ interface R2SqlTransportConfig {
27
27
  userAgent?: string;
28
28
  now?: () => number;
29
29
  }
30
- interface R2SqlTransport {
30
+ export interface R2SqlTransport {
31
31
  endpoint: string;
32
32
  query: (sql: string) => Promise<R2SqlTransportResult>;
33
33
  }
34
- declare function createR2SqlTransport(config: R2SqlTransportConfig): R2SqlTransport;
35
- export { R2SqlTransport, R2SqlTransportConfig, R2SqlTransportMetrics, R2SqlTransportResult, R2SqlTransportRow, createR2SqlTransport };
34
+ export declare function createR2SqlTransport(config: R2SqlTransportConfig): R2SqlTransport;
@@ -4,8 +4,25 @@ const MAX_RETRY_AFTER_MS = 6e4;
4
4
  function errorMessage(error) {
5
5
  return error instanceof Error ? error.message : String(error);
6
6
  }
7
+ function isRecord(value) {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9
+ }
7
10
  function parseEnvelope(value) {
8
- return value && typeof value === "object" ? value : null;
11
+ if (!isRecord(value) || typeof value.success !== "boolean") return null;
12
+ if (!value.success) {
13
+ if (value.errors !== void 0 && (!Array.isArray(value.errors) || !value.errors.every((error) => isRecord(error) && (error.message === void 0 || typeof error.message === "string")))) return null;
14
+ return value;
15
+ }
16
+ const result = value.result;
17
+ if (!isRecord(result)) return null;
18
+ if (result.rows !== void 0) {
19
+ if (!Array.isArray(result.rows) || !result.rows.every(isRecord)) return null;
20
+ } else {
21
+ const columns = result.columns;
22
+ const data = result.data;
23
+ if (!Array.isArray(columns) || !columns.every((column) => typeof column === "string") || !Array.isArray(data) || !data.every((tuple) => Array.isArray(tuple) && tuple.length === columns.length)) return null;
24
+ }
25
+ return value;
9
26
  }
10
27
  function normalizeRows(result) {
11
28
  if (Array.isArray(result?.rows)) return result.rows;
@@ -70,12 +87,18 @@ function createR2SqlTransport(config) {
70
87
  _tag: "error",
71
88
  error
72
89
  }));
73
- if (bodyResult._tag === "error") return {
74
- _tag: "error",
75
- kind: "network",
76
- message: `R2 SQL response read failed: ${errorMessage(bodyResult.error)}`,
77
- status: response.status
78
- };
90
+ if (bodyResult._tag === "error") {
91
+ if (controller.signal.aborted || bodyResult.error?.name === "AbortError") return {
92
+ _tag: "timeout",
93
+ timeoutMs
94
+ };
95
+ return {
96
+ _tag: "error",
97
+ kind: "network",
98
+ message: `R2 SQL response read failed: ${errorMessage(bodyResult.error)}`,
99
+ status: response.status
100
+ };
101
+ }
79
102
  const retryAfter = retryAfterMs(response, now);
80
103
  return {
81
104
  _tag: "error",
@@ -93,12 +116,18 @@ function createR2SqlTransport(config) {
93
116
  _tag: "error",
94
117
  error
95
118
  }));
96
- if (envelopeResult._tag === "error") return {
97
- _tag: "error",
98
- kind: "invalid_response",
99
- message: `R2 SQL returned invalid JSON: ${errorMessage(envelopeResult.error)}`,
100
- status: response.status
101
- };
119
+ if (envelopeResult._tag === "error") {
120
+ if (controller.signal.aborted || envelopeResult.error?.name === "AbortError") return {
121
+ _tag: "timeout",
122
+ timeoutMs
123
+ };
124
+ return {
125
+ _tag: "error",
126
+ kind: "invalid_response",
127
+ message: `R2 SQL returned invalid JSON: ${errorMessage(envelopeResult.error)}`,
128
+ status: response.status
129
+ };
130
+ }
102
131
  const envelope = parseEnvelope(envelopeResult.value);
103
132
  if (!envelope) return {
104
133
  _tag: "error",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gscdump/cloudflare",
3
3
  "type": "module",
4
- "version": "3.5.0",
4
+ "version": "3.6.0",
5
5
  "description": "Cloudflare Workers helpers for server-tail queries and in-flight request deduplication.",
6
6
  "author": {
7
7
  "name": "Harlan Wilton",
@@ -39,20 +39,24 @@
39
39
  "node": ">=22"
40
40
  },
41
41
  "dependencies": {
42
- "@gscdump/contracts": "^3.5.0",
43
- "@gscdump/engine": "^3.5.0",
44
- "gscdump": "^3.5.0"
42
+ "@gscdump/contracts": "^3.6.0",
43
+ "@gscdump/engine": "^3.6.0",
44
+ "gscdump": "^3.6.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@cloudflare/vitest-pool-workers": "^0.22.0",
48
- "@cloudflare/workers-types": "^5.20260831.1",
49
- "typescript": "^7.0.2",
50
- "wrangler": "^4.127.1"
48
+ "@cloudflare/workers-types": "^5.20260910.1",
49
+ "@vitest/browser-playwright": "^4.1.11",
50
+ "@vitest/ui": "^4.1.11",
51
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
52
+ "vitest": "^4.1.11",
53
+ "wrangler": "^4.130.0"
51
54
  },
52
55
  "scripts": {
53
56
  "build": "obuild",
54
57
  "dev": "obuild --stub",
55
58
  "typecheck": "tsc --noEmit",
59
+ "test": "vitest --run",
56
60
  "test:workers": "GSCDUMP_E2E=1 vitest --run --config vitest.workers.config.ts"
57
61
  }
58
62
  }