@shivaedev/test-sql 0.13.0 → 0.14.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/CHANGELOG.md +7 -0
- package/README.md +8 -0
- package/dist/server/adapters/environmentVariable.d.ts +2 -0
- package/dist/server/adapters/environmentVariable.d.ts.map +1 -0
- package/dist/server/adapters/environmentVariable.js +5 -0
- package/dist/server/adapters/environmentVariable.js.map +1 -0
- package/dist/server/adapters/notificationFilter.d.ts +3 -0
- package/dist/server/adapters/notificationFilter.d.ts.map +1 -0
- package/dist/server/adapters/notificationFilter.js +25 -0
- package/dist/server/adapters/notificationFilter.js.map +1 -0
- package/dist/server/adapters/openDisconnectProxy.d.ts +14 -0
- package/dist/server/adapters/openDisconnectProxy.d.ts.map +1 -0
- package/dist/server/adapters/openDisconnectProxy.js +110 -0
- package/dist/server/adapters/openDisconnectProxy.js.map +1 -0
- package/dist/server/disconnectProxy.d.ts +18 -0
- package/dist/server/disconnectProxy.d.ts.map +1 -0
- package/dist/server/disconnectProxy.js +6 -0
- package/dist/server/disconnectProxy.js.map +1 -0
- package/dist/server/emptyPostgres.d.ts +4 -0
- package/dist/server/emptyPostgres.d.ts.map +1 -0
- package/dist/server/emptyPostgres.js +10 -0
- package/dist/server/emptyPostgres.js.map +1 -0
- package/package.json +3 -3
- package/src/server/adapters/environmentVariable.ts +5 -0
- package/src/server/adapters/notificationFilter.ts +24 -0
- package/src/server/adapters/openDisconnectProxy.ts +110 -0
- package/src/server/disconnectProxy.ts +14 -0
- package/src/server/emptyPostgres.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0 - 2026-10-30
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- A scoped PostgreSQL disconnect proxy supports disconnecting, refusing and stalling traffic, holding disconnect acknowledgements, observing open clients and dropping LISTEN notifications. Test kits can share the environment reader and empty PostgreSQL template.
|
|
8
|
+
- Shared story answer readouts translate Effect failures into words for package test kits.
|
|
9
|
+
|
|
3
10
|
## 0.13.0 - 2026-10-30
|
|
4
11
|
|
|
5
12
|
### Added
|
package/README.md
CHANGED
|
@@ -249,6 +249,14 @@ With a PostgreSQL client in the current Context, the assertion compares names th
|
|
|
249
249
|
|
|
250
250
|
The assertion uses the SQL `Model`'s selected database fields, including generated ids and excluding JSON-only fields. Supply an exact PostgreSQL type for every field: schemas such as `Schema.Number` do not determine whether storage is `integer`, `numeric` or `double precision`. It fails with a typed `ModelColumnMismatch` containing all detected missing, extra and wrongly typed column problems. The type map is checked against the model both by TypeScript and at runtime. This check covers names and types; it does not check defaults, nullability, constraints or application data transformations.
|
|
251
251
|
|
|
252
|
+
### Database faults
|
|
253
|
+
|
|
254
|
+
`disconnectProxy` from `@shivaedev/test-sql/server/disconnectProxy.ts` acquires a loopback TCP proxy in the current Effect scope. Give its `url` to the application database client. `disconnect()` cuts existing connections, `setReachable(false)` also refuses new connections, and `setResponsive(false)` stalls traffic until `setResponsive(true)` resumes it. Closing the scope destroys the sockets and closes the listener.
|
|
255
|
+
|
|
256
|
+
For teardown stories, pass `{ holdDisconnects: true }`: `disconnecting` signals the first upstream disconnect, `allHeld` signals when every upstream has disconnected, and `release()` forwards the held acknowledgements. For LISTEN health stories, `{ dropNotifications: true }` filters PostgreSQL notifications while other SQL traffic continues; `dropped` signals a filtered notification.
|
|
257
|
+
|
|
258
|
+
`@shivaedev/test-sql/server/emptyPostgres.ts` provides `databaseUrl` and `postgres`, an empty migration template selected by `PLATFORM_SQL_TEST_DATABASE_URL`. Use it with `withIsolatedDatabase` for kits that initialize their own schema. `environmentVariable(name)` from `@shivaedev/test-sql/server/adapters/environmentVariable.ts` reads an optional string through Effect Config when declaring a kit's `runIf`.
|
|
259
|
+
|
|
252
260
|
### API
|
|
253
261
|
|
|
254
262
|
Import each name from the module that defines it. The package has no root entry. Every source module is available through its `.ts` subpath, including modules under `server/internal/`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"environmentVariable.d.ts","sourceRoot":"","sources":["../../../src/server/adapters/environmentVariable.ts"],"names":[],"mappings":"AAEA,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEpE"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"environmentVariable.js","sourceRoot":"","sources":["../../../src/server/adapters/environmentVariable.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhD,MAAM,UAAU,mBAAmB,CAAC,IAAY;IAC/C,OAAO,MAAM,CAAC,cAAc,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;AAClF,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notificationFilter.d.ts","sourceRoot":"","sources":["../../../src/server/adapters/notificationFilter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,IAAI,aAqBrD"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Transform } from "node:stream";
|
|
2
|
+
export function notificationFilter(dropped) {
|
|
3
|
+
let pending = Buffer.alloc(0);
|
|
4
|
+
const filter = new Transform({
|
|
5
|
+
transform(chunk, _encoding, callback) {
|
|
6
|
+
pending = Buffer.concat([pending, chunk]);
|
|
7
|
+
while (pending.length >= 5) {
|
|
8
|
+
const length = pending.readInt32BE(1) + 1;
|
|
9
|
+
if (pending.length < length) {
|
|
10
|
+
break;
|
|
11
|
+
}
|
|
12
|
+
if (pending[0] === 65) {
|
|
13
|
+
dropped();
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
filter.push(pending.subarray(0, length));
|
|
17
|
+
}
|
|
18
|
+
pending = pending.subarray(length);
|
|
19
|
+
}
|
|
20
|
+
callback();
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return filter;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=notificationFilter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notificationFilter.js","sourceRoot":"","sources":["../../../src/server/adapters/notificationFilter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,MAAM,UAAU,kBAAkB,CAAC,OAAmB;IACrD,IAAI,OAAO,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC9B,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC;QAC5B,SAAS,CAAC,KAAa,EAAE,SAAS,EAAE,QAAQ;YAC3C,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;YAC1C,OAAO,OAAO,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;gBAC5B,MAAM,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;gBAC1C,IAAI,OAAO,CAAC,MAAM,GAAG,MAAM,EAAE,CAAC;oBAC7B,MAAM;gBACP,CAAC;gBACD,IAAI,OAAO,CAAC,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC;oBACvB,OAAO,EAAE,CAAC;gBACX,CAAC;qBAAM,CAAC;oBACP,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;gBAC1C,CAAC;gBACD,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YACpC,CAAC;YACD,QAAQ,EAAE,CAAC;QACZ,CAAC;KACD,CAAC,CAAC;IACH,OAAO,MAAM,CAAC;AACf,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DisconnectProxyOptions } from "#disconnectProxy.ts";
|
|
2
|
+
export declare function openDisconnectProxy(destination: string, options: DisconnectProxyOptions): Promise<{
|
|
3
|
+
allHeld: Promise<void>;
|
|
4
|
+
close: () => Promise<void>;
|
|
5
|
+
disconnect: () => void;
|
|
6
|
+
disconnecting: Promise<void>;
|
|
7
|
+
dropped: Promise<void>;
|
|
8
|
+
openClients: () => number;
|
|
9
|
+
release: () => void;
|
|
10
|
+
setReachable: (value: boolean) => void;
|
|
11
|
+
setResponsive: (value: boolean) => void;
|
|
12
|
+
url: string;
|
|
13
|
+
}>;
|
|
14
|
+
//# sourceMappingURL=openDisconnectProxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openDisconnectProxy.d.ts","sourceRoot":"","sources":["../../../src/server/adapters/openDisconnectProxy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC;AAGlE,wBAAsB,mBAAmB,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,sBAAsB;;;;;;;;0BAuFtE,OAAO;2BAMN,OAAO;;GAY/B"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createConnection, createServer } from "node:net";
|
|
2
|
+
import { notificationFilter } from "./notificationFilter.js";
|
|
3
|
+
export async function openDisconnectProxy(destination, options) {
|
|
4
|
+
const target = new URL(destination);
|
|
5
|
+
const address = { host: target.hostname, port: Number(target.port || 5432) };
|
|
6
|
+
const sockets = new Set();
|
|
7
|
+
const clients = new Set();
|
|
8
|
+
const pending = new Set();
|
|
9
|
+
const disconnecting = Promise.withResolvers();
|
|
10
|
+
const allHeld = Promise.withResolvers();
|
|
11
|
+
const dropped = Promise.withResolvers();
|
|
12
|
+
let held = options.holdDisconnects === true;
|
|
13
|
+
let reachable = true;
|
|
14
|
+
let responsive = true;
|
|
15
|
+
let upstreams = 0;
|
|
16
|
+
function disconnect() {
|
|
17
|
+
for (const socket of sockets) {
|
|
18
|
+
socket.destroy();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function release() {
|
|
22
|
+
held = false;
|
|
23
|
+
for (const socket of pending) {
|
|
24
|
+
socket.end();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const server = createServer({ allowHalfOpen: true }, (client) => {
|
|
28
|
+
if (!reachable) {
|
|
29
|
+
client.destroy();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const upstream = createConnection(address);
|
|
33
|
+
clients.add(client);
|
|
34
|
+
upstreams += 1;
|
|
35
|
+
for (const socket of [client, upstream]) {
|
|
36
|
+
sockets.add(socket);
|
|
37
|
+
socket.on("close", () => sockets.delete(socket));
|
|
38
|
+
socket.on("error", () => {
|
|
39
|
+
client.destroy();
|
|
40
|
+
upstream.destroy();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
client.pipe(upstream, { end: false });
|
|
44
|
+
const incoming = options.dropNotifications === true ? upstream.pipe(notificationFilter(dropped.resolve)) : upstream;
|
|
45
|
+
incoming.pipe(client, { end: false });
|
|
46
|
+
client.on("end", () => {
|
|
47
|
+
clients.delete(client);
|
|
48
|
+
upstream.end();
|
|
49
|
+
});
|
|
50
|
+
client.on("close", () => clients.delete(client));
|
|
51
|
+
upstream.on("end", () => {
|
|
52
|
+
pending.add(client);
|
|
53
|
+
disconnecting.resolve();
|
|
54
|
+
if (pending.size === upstreams) {
|
|
55
|
+
allHeld.resolve();
|
|
56
|
+
}
|
|
57
|
+
if (!held) {
|
|
58
|
+
client.end();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
if (!responsive) {
|
|
62
|
+
client.pause();
|
|
63
|
+
upstream.pause();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
await new Promise((resolve, reject) => {
|
|
67
|
+
server.once("error", reject);
|
|
68
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
69
|
+
});
|
|
70
|
+
const listening = server.address();
|
|
71
|
+
if (listening === null || typeof listening === "string") {
|
|
72
|
+
throw new Error("Expected a TCP listener");
|
|
73
|
+
}
|
|
74
|
+
target.hostname = "127.0.0.1";
|
|
75
|
+
target.port = String(listening.port);
|
|
76
|
+
if (options.dropNotifications === true) {
|
|
77
|
+
target.searchParams.set("sslmode", "disable");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
allHeld: allHeld.promise,
|
|
81
|
+
close: async () => {
|
|
82
|
+
disconnect();
|
|
83
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
84
|
+
},
|
|
85
|
+
disconnect,
|
|
86
|
+
disconnecting: disconnecting.promise,
|
|
87
|
+
dropped: dropped.promise,
|
|
88
|
+
openClients: () => clients.size,
|
|
89
|
+
release,
|
|
90
|
+
setReachable: (value) => {
|
|
91
|
+
reachable = value;
|
|
92
|
+
if (!value) {
|
|
93
|
+
disconnect();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
setResponsive: (value) => {
|
|
97
|
+
responsive = value;
|
|
98
|
+
for (const socket of sockets) {
|
|
99
|
+
if (value) {
|
|
100
|
+
socket.resume();
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
socket.pause();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
url: target.toString(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=openDisconnectProxy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openDisconnectProxy.js","sourceRoot":"","sources":["../../../src/server/adapters/openDisconnectProxy.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
export interface DisconnectProxyOptions {
|
|
3
|
+
readonly dropNotifications?: boolean;
|
|
4
|
+
readonly holdDisconnects?: boolean;
|
|
5
|
+
}
|
|
6
|
+
export declare const disconnectProxy: (destination: string, options?: DisconnectProxyOptions | undefined) => Effect.Effect<{
|
|
7
|
+
allHeld: Promise<void>;
|
|
8
|
+
close: () => Promise<void>;
|
|
9
|
+
disconnect: () => void;
|
|
10
|
+
disconnecting: Promise<void>;
|
|
11
|
+
dropped: Promise<void>;
|
|
12
|
+
openClients: () => number;
|
|
13
|
+
release: () => void;
|
|
14
|
+
setReachable: (value: boolean) => void;
|
|
15
|
+
setResponsive: (value: boolean) => void;
|
|
16
|
+
url: string;
|
|
17
|
+
}, never, import("effect/Scope").Scope>;
|
|
18
|
+
//# sourceMappingURL=disconnectProxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"disconnectProxy.d.ts","sourceRoot":"","sources":["../../src/server/disconnectProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGhC,MAAM,WAAW,sBAAsB;IACtC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC;CACnC;AAED,eAAO,MAAM,eAAe;;;;;;;;;;;uCAK1B,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { openDisconnectProxy } from "#adapters/openDisconnectProxy.ts";
|
|
3
|
+
export const disconnectProxy = Effect.fnUntraced(function* (destination, options = {}) {
|
|
4
|
+
return yield* Effect.acquireRelease(Effect.promise(() => openDisconnectProxy(destination, options)), (proxy) => Effect.promise(proxy.close));
|
|
5
|
+
});
|
|
6
|
+
//# sourceMappingURL=disconnectProxy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"disconnectProxy.js","sourceRoot":"","sources":["../../src/server/disconnectProxy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AAOvE,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,WAAmB,EAAE,OAAO,GAA2B,EAAE;IACpH,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,cAAc,CAClC,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,mBAAmB,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC,EAC/D,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CACtC,CAAC;AACH,CAAC,CAAC,CAAC"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Migrator } from "effect/sql";
|
|
2
|
+
export declare const databaseUrl: string | undefined;
|
|
3
|
+
export declare const postgres: import("effect/Layer").Layer<import("@effect/sql-pg/PgClient").PgClient | import("./database.ts").PostgresTestDatabase | import("effect/reactivity/Reactivity").Reactivity | import("effect/sql/SqlClient").SqlClient, Migrator.MigrationError | import("effect/Schema").SchemaError | import("effect/sql/SqlError").SqlError, never>;
|
|
4
|
+
//# sourceMappingURL=emptyPostgres.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"emptyPostgres.d.ts","sourceRoot":"","sources":["../../src/server/emptyPostgres.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAItC,eAAO,MAAM,WAAW,oBAAwD,CAAC;AACjF,eAAO,MAAM,QAAQ,uUAGnB,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Redacted } from "effect";
|
|
2
|
+
import { Migrator } from "effect/sql";
|
|
3
|
+
import { environmentVariable } from "#adapters/environmentVariable.ts";
|
|
4
|
+
import { makePostgresTestLayer } from "#postgres.ts";
|
|
5
|
+
export const databaseUrl = environmentVariable("PLATFORM_SQL_TEST_DATABASE_URL");
|
|
6
|
+
export const postgres = makePostgresTestLayer({
|
|
7
|
+
migrations: { loader: Migrator.fromRecord({}), lockTimeout: "5 seconds" },
|
|
8
|
+
url: Redacted.make(databaseUrl ?? "postgresql://integration-tests-disabled"),
|
|
9
|
+
});
|
|
10
|
+
//# sourceMappingURL=emptyPostgres.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"emptyPostgres.js","sourceRoot":"","sources":["../../src/server/emptyPostgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,QAAQ,CAAC;AAClC,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACtC,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AACvE,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAErD,MAAM,CAAC,MAAM,WAAW,GAAG,mBAAmB,CAAC,gCAAgC,CAAC,CAAC;AACjF,MAAM,CAAC,MAAM,QAAQ,GAAG,qBAAqB,CAAC;IAC7C,UAAU,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC,EAAE,WAAW,EAAE,WAAW,EAAE;IACzE,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,WAAW,IAAI,yCAAyC,CAAC;CAC5E,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/test-sql",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "PostgreSQL rollback and real-commit test isolation for Effect",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect"
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"@shivaedev/jobs": "0.23.1",
|
|
52
52
|
"@shivaedev/quality": "0.47.0",
|
|
53
53
|
"@shivaedev/service": "0.6.0",
|
|
54
|
-
"@shivaedev/test": "0.
|
|
55
|
-
"@shivaedev/test-story": "0.
|
|
54
|
+
"@shivaedev/test": "0.12.0",
|
|
55
|
+
"@shivaedev/test-story": "0.26.0",
|
|
56
56
|
"@types/node": "24.19.2",
|
|
57
57
|
"effect": "4.0.6",
|
|
58
58
|
"pg-boss": "12.38.0",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Transform } from "node:stream";
|
|
2
|
+
|
|
3
|
+
export function notificationFilter(dropped: () => void) {
|
|
4
|
+
let pending = Buffer.alloc(0);
|
|
5
|
+
const filter = new Transform({
|
|
6
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
7
|
+
pending = Buffer.concat([pending, chunk]);
|
|
8
|
+
while (pending.length >= 5) {
|
|
9
|
+
const length = pending.readInt32BE(1) + 1;
|
|
10
|
+
if (pending.length < length) {
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
13
|
+
if (pending[0] === 65) {
|
|
14
|
+
dropped();
|
|
15
|
+
} else {
|
|
16
|
+
filter.push(pending.subarray(0, length));
|
|
17
|
+
}
|
|
18
|
+
pending = pending.subarray(length);
|
|
19
|
+
}
|
|
20
|
+
callback();
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return filter;
|
|
24
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createConnection, createServer, type Socket } from "node:net";
|
|
2
|
+
import type { DisconnectProxyOptions } from "#disconnectProxy.ts";
|
|
3
|
+
import { notificationFilter } from "./notificationFilter.ts";
|
|
4
|
+
|
|
5
|
+
export async function openDisconnectProxy(destination: string, options: DisconnectProxyOptions) {
|
|
6
|
+
const target = new URL(destination);
|
|
7
|
+
const address = { host: target.hostname, port: Number(target.port || 5432) };
|
|
8
|
+
const sockets = new Set<Socket>();
|
|
9
|
+
const clients = new Set<Socket>();
|
|
10
|
+
const pending = new Set<Socket>();
|
|
11
|
+
const disconnecting = Promise.withResolvers<void>();
|
|
12
|
+
const allHeld = Promise.withResolvers<void>();
|
|
13
|
+
const dropped = Promise.withResolvers<void>();
|
|
14
|
+
let held = options.holdDisconnects === true;
|
|
15
|
+
let reachable = true;
|
|
16
|
+
let responsive = true;
|
|
17
|
+
let upstreams = 0;
|
|
18
|
+
function disconnect() {
|
|
19
|
+
for (const socket of sockets) {
|
|
20
|
+
socket.destroy();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function release() {
|
|
24
|
+
held = false;
|
|
25
|
+
for (const socket of pending) {
|
|
26
|
+
socket.end();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const server = createServer({ allowHalfOpen: true }, (client) => {
|
|
30
|
+
if (!reachable) {
|
|
31
|
+
client.destroy();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const upstream = createConnection(address);
|
|
35
|
+
clients.add(client);
|
|
36
|
+
upstreams += 1;
|
|
37
|
+
for (const socket of [client, upstream]) {
|
|
38
|
+
sockets.add(socket);
|
|
39
|
+
socket.on("close", () => sockets.delete(socket));
|
|
40
|
+
socket.on("error", () => {
|
|
41
|
+
client.destroy();
|
|
42
|
+
upstream.destroy();
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
client.pipe(upstream, { end: false });
|
|
46
|
+
const incoming = options.dropNotifications === true ? upstream.pipe(notificationFilter(dropped.resolve)) : upstream;
|
|
47
|
+
incoming.pipe(client, { end: false });
|
|
48
|
+
client.on("end", () => {
|
|
49
|
+
clients.delete(client);
|
|
50
|
+
upstream.end();
|
|
51
|
+
});
|
|
52
|
+
client.on("close", () => clients.delete(client));
|
|
53
|
+
upstream.on("end", () => {
|
|
54
|
+
pending.add(client);
|
|
55
|
+
disconnecting.resolve();
|
|
56
|
+
if (pending.size === upstreams) {
|
|
57
|
+
allHeld.resolve();
|
|
58
|
+
}
|
|
59
|
+
if (!held) {
|
|
60
|
+
client.end();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
if (!responsive) {
|
|
64
|
+
client.pause();
|
|
65
|
+
upstream.pause();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await new Promise<void>((resolve, reject) => {
|
|
69
|
+
server.once("error", reject);
|
|
70
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
71
|
+
});
|
|
72
|
+
const listening = server.address();
|
|
73
|
+
if (listening === null || typeof listening === "string") {
|
|
74
|
+
throw new Error("Expected a TCP listener");
|
|
75
|
+
}
|
|
76
|
+
target.hostname = "127.0.0.1";
|
|
77
|
+
target.port = String(listening.port);
|
|
78
|
+
if (options.dropNotifications === true) {
|
|
79
|
+
target.searchParams.set("sslmode", "disable");
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
allHeld: allHeld.promise,
|
|
83
|
+
close: async () => {
|
|
84
|
+
disconnect();
|
|
85
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
86
|
+
},
|
|
87
|
+
disconnect,
|
|
88
|
+
disconnecting: disconnecting.promise,
|
|
89
|
+
dropped: dropped.promise,
|
|
90
|
+
openClients: () => clients.size,
|
|
91
|
+
release,
|
|
92
|
+
setReachable: (value: boolean) => {
|
|
93
|
+
reachable = value;
|
|
94
|
+
if (!value) {
|
|
95
|
+
disconnect();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
setResponsive: (value: boolean) => {
|
|
99
|
+
responsive = value;
|
|
100
|
+
for (const socket of sockets) {
|
|
101
|
+
if (value) {
|
|
102
|
+
socket.resume();
|
|
103
|
+
} else {
|
|
104
|
+
socket.pause();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
url: target.toString(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { openDisconnectProxy } from "#adapters/openDisconnectProxy.ts";
|
|
3
|
+
|
|
4
|
+
export interface DisconnectProxyOptions {
|
|
5
|
+
readonly dropNotifications?: boolean;
|
|
6
|
+
readonly holdDisconnects?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const disconnectProxy = Effect.fnUntraced(function* (destination: string, options: DisconnectProxyOptions = {}) {
|
|
10
|
+
return yield* Effect.acquireRelease(
|
|
11
|
+
Effect.promise(() => openDisconnectProxy(destination, options)),
|
|
12
|
+
(proxy) => Effect.promise(proxy.close),
|
|
13
|
+
);
|
|
14
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Redacted } from "effect";
|
|
2
|
+
import { Migrator } from "effect/sql";
|
|
3
|
+
import { environmentVariable } from "#adapters/environmentVariable.ts";
|
|
4
|
+
import { makePostgresTestLayer } from "#postgres.ts";
|
|
5
|
+
|
|
6
|
+
export const databaseUrl = environmentVariable("PLATFORM_SQL_TEST_DATABASE_URL");
|
|
7
|
+
export const postgres = makePostgresTestLayer({
|
|
8
|
+
migrations: { loader: Migrator.fromRecord({}), lockTimeout: "5 seconds" },
|
|
9
|
+
url: Redacted.make(databaseUrl ?? "postgresql://integration-tests-disabled"),
|
|
10
|
+
});
|