@porulle/adapter-neon 0.35.1 → 0.37.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/dist/index.d.ts CHANGED
@@ -19,6 +19,36 @@ export interface NeonAdapterOptions {
19
19
  } | undefined;
20
20
  }
21
21
  export type NeonDatabaseAdapter = DatabaseAdapter<HttpClient, unknown>;
22
+ /**
23
+ * Run `fn` with ONE Hyperdrive client shared by every transaction it takes, closed when `fn`
24
+ * settles either way.
25
+ *
26
+ * Why it exists. `runInHyperdriveClient` below used to open and end a Postgres.js client per
27
+ * `transaction()` call. Measured on a deployed Worker on 2026-09-15: one import of 100 products
28
+ * opened **63,355** of them, and a probe from inside that same Worker priced a client at 4 ms on
29
+ * the medians and 7.7 ms on the means against a reused one, 88 ms on the first — on the order of
30
+ * 250 to 500 seconds inside a 985-second import, paid for nothing.
31
+ *
32
+ * Why it is a scope and not a module-level client. A Worker may not reuse a socket across
33
+ * invocations; an I/O object created in one request context throws when touched from another. So
34
+ * the reuse is bounded by whatever the caller declares an invocation to be — a fetch, a queue
35
+ * batch, a Workflow step — and the caller opens the scope. Nothing here is assumed to survive
36
+ * past it.
37
+ *
38
+ * Why plain queries are NOT routed through it. The same probe measured Neon HTTP at 6.76 ms per
39
+ * query against this client's 8.02 ms, with identical 7 ms medians. Moving them here would be
40
+ * slower, and an HTTP query costs no connection at all.
41
+ *
42
+ * Hyperdrive's limits bound the scope: a query may run for at most 60 s and an idle connection is
43
+ * dropped after 10 minutes. The client below raises Postgres.js's own `idle_timeout` from 5 s to
44
+ * 30 s so a gap between two transactions in an import loop — about 2.6 s at the measured rate —
45
+ * does not silently close and reopen the connection this function exists to hold, while staying
46
+ * far inside Hyperdrive's own window.
47
+ *
48
+ * Nested calls join the enclosing scope rather than opening a second client, so a caller that
49
+ * wraps both its handler and an inner unit of work gets one connection, not two.
50
+ */
51
+ export declare function withPooledTransactions<T>(fn: () => Promise<T>): Promise<T>;
22
52
  /**
23
53
  * Normalizes `.execute()` to the postgres-js shape (array of rows). Core and
24
54
  * custom routes iterate `.execute()` results directly; the raw neon drivers
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAoBA,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACtF,OAAO,EAAwB,KAAK,YAAY,EAAE,MAAM,6BAA6B,CAAC;AACtF,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAExF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAMrD,KAAK,UAAU,GAAG,gBAAgB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAC5D,KAAK,QAAQ,GAAG,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AACtD,KAAK,QAAQ,GAAG,kBAAkB,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;AAC1D,KAAK,KAAK,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE9C,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,UAAU,CAAC,EAAE;QAAE,gBAAgB,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACvD;AAED,MAAM,MAAM,mBAAmB,GAAG,eAAe,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;AAEvE;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,KAAK,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,CAsB/D;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,mBAAmB,CA0D5E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAqBA,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AACtF,OAAO,EAAwB,KAAK,YAAY,EAAE,MAAM,6BAA6B,CAAC;AACtF,OAAO,EAAwB,KAAK,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAExF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAMrD,KAAK,UAAU,GAAG,gBAAgB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAC5D,KAAK,QAAQ,GAAG,YAAY,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AACtD,KAAK,QAAQ,GAAG,kBAAkB,CAAC,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC;AAC1D,KAAK,KAAK,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE9C,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,UAAU,CAAC,EAAE;QAAE,gBAAgB,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CACvD;AAED,MAAM,MAAM,mBAAmB,GAAG,eAAe,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;AAUvE;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,sBAAsB,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAahF;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS,KAAK,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,CAsB/D;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,mBAAmB,CAgF5E"}
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@
17
17
  * WebSocket client cannot speak to Hyperdrive's TCP endpoint. The HTTP driver
18
18
  * always speaks directly to Neon, so a direct `connectionString` is required.
19
19
  */
20
+ import { AsyncLocalStorage } from "node:async_hooks";
20
21
  import { Pool, neon, neonConfig } from "@neondatabase/serverless";
21
22
  import { drizzle as drizzleHttp } from "drizzle-orm/neon-http";
22
23
  import { drizzle as drizzleWs } from "drizzle-orm/neon-serverless";
@@ -25,6 +26,52 @@ import postgres from "postgres";
25
26
  if (typeof WebSocket !== "undefined") {
26
27
  neonConfig.webSocketConstructor = WebSocket;
27
28
  }
29
+ const pooledScope = new AsyncLocalStorage();
30
+ /**
31
+ * Run `fn` with ONE Hyperdrive client shared by every transaction it takes, closed when `fn`
32
+ * settles either way.
33
+ *
34
+ * Why it exists. `runInHyperdriveClient` below used to open and end a Postgres.js client per
35
+ * `transaction()` call. Measured on a deployed Worker on 2026-09-15: one import of 100 products
36
+ * opened **63,355** of them, and a probe from inside that same Worker priced a client at 4 ms on
37
+ * the medians and 7.7 ms on the means against a reused one, 88 ms on the first — on the order of
38
+ * 250 to 500 seconds inside a 985-second import, paid for nothing.
39
+ *
40
+ * Why it is a scope and not a module-level client. A Worker may not reuse a socket across
41
+ * invocations; an I/O object created in one request context throws when touched from another. So
42
+ * the reuse is bounded by whatever the caller declares an invocation to be — a fetch, a queue
43
+ * batch, a Workflow step — and the caller opens the scope. Nothing here is assumed to survive
44
+ * past it.
45
+ *
46
+ * Why plain queries are NOT routed through it. The same probe measured Neon HTTP at 6.76 ms per
47
+ * query against this client's 8.02 ms, with identical 7 ms medians. Moving them here would be
48
+ * slower, and an HTTP query costs no connection at all.
49
+ *
50
+ * Hyperdrive's limits bound the scope: a query may run for at most 60 s and an idle connection is
51
+ * dropped after 10 minutes. The client below raises Postgres.js's own `idle_timeout` from 5 s to
52
+ * 30 s so a gap between two transactions in an import loop — about 2.6 s at the measured rate —
53
+ * does not silently close and reopen the connection this function exists to hold, while staying
54
+ * far inside Hyperdrive's own window.
55
+ *
56
+ * Nested calls join the enclosing scope rather than opening a second client, so a caller that
57
+ * wraps both its handler and an inner unit of work gets one connection, not two.
58
+ */
59
+ export async function withPooledTransactions(fn) {
60
+ if (pooledScope.getStore())
61
+ return fn();
62
+ const scope = { client: undefined };
63
+ try {
64
+ return await pooledScope.run(scope, fn);
65
+ }
66
+ finally {
67
+ const client = scope.client;
68
+ scope.client = undefined;
69
+ // Both paths, deliberately: a failed invocation that leaks its connection is worse than one
70
+ // that never pooled, because the leak is invisible until Hyperdrive runs out of them.
71
+ if (client)
72
+ await client.end({ timeout: 1 }).catch(() => { });
73
+ }
74
+ }
28
75
  /**
29
76
  * Normalizes `.execute()` to the postgres-js shape (array of rows). Core and
30
77
  * custom routes iterate `.execute()` results directly; the raw neon drivers
@@ -67,7 +114,28 @@ export function neonAdapter(options) {
67
114
  await pool.end().catch(() => { });
68
115
  }
69
116
  };
70
- const runInFreshHyperdriveClient = async (fn) => {
117
+ const runInHyperdriveClient = async (fn) => {
118
+ const runOn = async (client) => {
119
+ const pgDb = normalizeExecuteShape(drizzlePg(client));
120
+ return await pgDb.transaction(async (tx) => fn(normalizeExecuteShape(tx)));
121
+ };
122
+ // Inside a `withPooledTransactions` scope the client is created once and owned by the scope,
123
+ // which closes it on both the success and the failure path. Closing it here instead would
124
+ // defeat the reuse and pull the connection out from under the transactions still to come.
125
+ const scope = pooledScope.getStore();
126
+ if (scope) {
127
+ scope.client ??= postgres(options.hyperdrive.connectionString, {
128
+ max: 1,
129
+ prepare: false,
130
+ connect_timeout: 10,
131
+ // 30 s rather than the 5 s below: the gap between two transactions in an import loop is
132
+ // about 2.6 s at the measured rate, close enough to 5 s that the connection this scope
133
+ // exists to hold would close and reopen anyway. Far inside Hyperdrive's own 10-minute
134
+ // idle timeout.
135
+ idle_timeout: 30,
136
+ });
137
+ return await runOn(scope.client);
138
+ }
71
139
  const client = postgres(options.hyperdrive.connectionString, {
72
140
  max: 1,
73
141
  prepare: false,
@@ -75,15 +143,14 @@ export function neonAdapter(options) {
75
143
  idle_timeout: 5,
76
144
  });
77
145
  try {
78
- const pgDb = normalizeExecuteShape(drizzlePg(client));
79
- return await pgDb.transaction(async (tx) => fn(normalizeExecuteShape(tx)));
146
+ return await runOn(client);
80
147
  }
81
148
  finally {
82
149
  await client.end({ timeout: 1 }).catch(() => { });
83
150
  }
84
151
  };
85
152
  const runTransaction = options.hyperdrive
86
- ? runInFreshHyperdriveClient
153
+ ? runInHyperdriveClient
87
154
  : runInFreshNeonPool;
88
155
  // Some core paths call `kernel.database.db.transaction(...)` directly —
89
156
  // splice the pool-backed transaction onto the HTTP client so both entry
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/adapter-neon",
3
- "version": "0.35.1",
3
+ "version": "0.37.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,15 +14,15 @@
14
14
  "@neondatabase/serverless": "^1.0.1",
15
15
  "drizzle-orm": "^0.45.1",
16
16
  "postgres": "^3.4.7",
17
- "@porulle/core": "0.35.1"
17
+ "@porulle/core": "0.37.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^24.5.2",
21
21
  "eslint": "^9.39.1",
22
22
  "typescript": "5.9.2",
23
23
  "vitest": "^3.2.4",
24
- "@porulle/eslint-config": "0.1.0",
25
- "@porulle/typescript-config": "0.1.0"
24
+ "@porulle/typescript-config": "0.1.0",
25
+ "@porulle/eslint-config": "0.1.0"
26
26
  },
27
27
  "publishConfig": {
28
28
  "access": "public"
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * WebSocket client cannot speak to Hyperdrive's TCP endpoint. The HTTP driver
18
18
  * always speaks directly to Neon, so a direct `connectionString` is required.
19
19
  */
20
+ import { AsyncLocalStorage } from "node:async_hooks";
20
21
  import { Pool, neon, neonConfig } from "@neondatabase/serverless";
21
22
  import { drizzle as drizzleHttp, type NeonHttpDatabase } from "drizzle-orm/neon-http";
22
23
  import { drizzle as drizzleWs, type NeonDatabase } from "drizzle-orm/neon-serverless";
@@ -46,6 +47,58 @@ export interface NeonAdapterOptions {
46
47
 
47
48
  export type NeonDatabaseAdapter = DatabaseAdapter<HttpClient, unknown>;
48
49
 
50
+ type PostgresClient = ReturnType<typeof postgres>;
51
+
52
+ interface PooledScope {
53
+ client: PostgresClient | undefined;
54
+ }
55
+
56
+ const pooledScope = new AsyncLocalStorage<PooledScope>();
57
+
58
+ /**
59
+ * Run `fn` with ONE Hyperdrive client shared by every transaction it takes, closed when `fn`
60
+ * settles either way.
61
+ *
62
+ * Why it exists. `runInHyperdriveClient` below used to open and end a Postgres.js client per
63
+ * `transaction()` call. Measured on a deployed Worker on 2026-09-15: one import of 100 products
64
+ * opened **63,355** of them, and a probe from inside that same Worker priced a client at 4 ms on
65
+ * the medians and 7.7 ms on the means against a reused one, 88 ms on the first — on the order of
66
+ * 250 to 500 seconds inside a 985-second import, paid for nothing.
67
+ *
68
+ * Why it is a scope and not a module-level client. A Worker may not reuse a socket across
69
+ * invocations; an I/O object created in one request context throws when touched from another. So
70
+ * the reuse is bounded by whatever the caller declares an invocation to be — a fetch, a queue
71
+ * batch, a Workflow step — and the caller opens the scope. Nothing here is assumed to survive
72
+ * past it.
73
+ *
74
+ * Why plain queries are NOT routed through it. The same probe measured Neon HTTP at 6.76 ms per
75
+ * query against this client's 8.02 ms, with identical 7 ms medians. Moving them here would be
76
+ * slower, and an HTTP query costs no connection at all.
77
+ *
78
+ * Hyperdrive's limits bound the scope: a query may run for at most 60 s and an idle connection is
79
+ * dropped after 10 minutes. The client below raises Postgres.js's own `idle_timeout` from 5 s to
80
+ * 30 s so a gap between two transactions in an import loop — about 2.6 s at the measured rate —
81
+ * does not silently close and reopen the connection this function exists to hold, while staying
82
+ * far inside Hyperdrive's own window.
83
+ *
84
+ * Nested calls join the enclosing scope rather than opening a second client, so a caller that
85
+ * wraps both its handler and an inner unit of work gets one connection, not two.
86
+ */
87
+ export async function withPooledTransactions<T>(fn: () => Promise<T>): Promise<T> {
88
+ if (pooledScope.getStore()) return fn();
89
+
90
+ const scope: PooledScope = { client: undefined };
91
+ try {
92
+ return await pooledScope.run(scope, fn);
93
+ } finally {
94
+ const client = scope.client;
95
+ scope.client = undefined;
96
+ // Both paths, deliberately: a failed invocation that leaks its connection is worse than one
97
+ // that never pooled, because the leak is invisible until Hyperdrive runs out of them.
98
+ if (client) await client.end({ timeout: 1 }).catch(() => {});
99
+ }
100
+ }
101
+
49
102
  /**
50
103
  * Normalizes `.execute()` to the postgres-js shape (array of rows). Core and
51
104
  * custom routes iterate `.execute()` results directly; the raw neon drivers
@@ -93,9 +146,32 @@ export function neonAdapter(options: NeonAdapterOptions): NeonDatabaseAdapter {
93
146
  }
94
147
  };
95
148
 
96
- const runInFreshHyperdriveClient = async (
149
+ const runInHyperdriveClient = async (
97
150
  fn: (tx: unknown) => Promise<unknown>,
98
151
  ): Promise<unknown> => {
152
+ const runOn = async (client: PostgresClient) => {
153
+ const pgDb = normalizeExecuteShape(drizzlePg(client) as PgClient);
154
+ return await pgDb.transaction(async (tx) => fn(normalizeExecuteShape(tx as PgClient)));
155
+ };
156
+
157
+ // Inside a `withPooledTransactions` scope the client is created once and owned by the scope,
158
+ // which closes it on both the success and the failure path. Closing it here instead would
159
+ // defeat the reuse and pull the connection out from under the transactions still to come.
160
+ const scope = pooledScope.getStore();
161
+ if (scope) {
162
+ scope.client ??= postgres(options.hyperdrive!.connectionString, {
163
+ max: 1,
164
+ prepare: false,
165
+ connect_timeout: 10,
166
+ // 30 s rather than the 5 s below: the gap between two transactions in an import loop is
167
+ // about 2.6 s at the measured rate, close enough to 5 s that the connection this scope
168
+ // exists to hold would close and reopen anyway. Far inside Hyperdrive's own 10-minute
169
+ // idle timeout.
170
+ idle_timeout: 30,
171
+ });
172
+ return await runOn(scope.client);
173
+ }
174
+
99
175
  const client = postgres(options.hyperdrive!.connectionString, {
100
176
  max: 1,
101
177
  prepare: false,
@@ -103,15 +179,14 @@ export function neonAdapter(options: NeonAdapterOptions): NeonDatabaseAdapter {
103
179
  idle_timeout: 5,
104
180
  });
105
181
  try {
106
- const pgDb = normalizeExecuteShape(drizzlePg(client) as PgClient);
107
- return await pgDb.transaction(async (tx) => fn(normalizeExecuteShape(tx as PgClient)));
182
+ return await runOn(client);
108
183
  } finally {
109
184
  await client.end({ timeout: 1 }).catch(() => {});
110
185
  }
111
186
  };
112
187
 
113
188
  const runTransaction = options.hyperdrive
114
- ? runInFreshHyperdriveClient
189
+ ? runInHyperdriveClient
115
190
  : runInFreshNeonPool;
116
191
 
117
192
  // Some core paths call `kernel.database.db.transaction(...)` directly —