Skip to content

Commit ffcbb79

Browse files
authored
feat: Merge pull request #28 from seamapi/feat-use-birpc
2 parents f32f204 + 3e31180 commit ffcbb79

File tree

10 files changed

+293
-358
lines changed

10 files changed

+293
-358
lines changed

.github/workflows/npm-semantic-release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
- name: Setup Node.js
1616
uses: actions/setup-node@v2
1717
with:
18-
node-version: "16.x"
18+
node-version: "18.x"
1919
- name: Install dependencies
2020
run: yarn install
2121
- name: Build

.github/workflows/prettier.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
- name: Setup Node.js
1414
uses: actions/setup-node@v2
1515
with:
16-
node-version: 16
16+
node-version: 18
1717
- name: Install dependencies
1818
run: yarn install
1919
- name: Run Prettier Test

.github/workflows/test.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ jobs:
88
name: Run NPM Test
99
strategy:
1010
matrix:
11-
node-version: [14.x, 16.x, 18.x]
12-
postgres-version: [13, 14, 15]
11+
node-version: [18.x, 20.x]
12+
postgres-version: [14, 15, 16]
1313
runs-on: ubuntu-20.04
1414
steps:
1515
- name: Checkout

.github/workflows/type-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
- name: Setup Node.js
1212
uses: actions/setup-node@v2
1313
with:
14-
node-version: 16
14+
node-version: 18
1515
cache: "yarn"
1616
- name: Run NPM Install
1717
run: yarn install --frozen-lockfile

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@semantic-release/git": "10.0.1",
2929
"@semantic-release/npm": "9.0.1",
3030
"@semantic-release/release-notes-generator": "10.0.3",
31+
"@types/lodash": "4.17.0",
3132
"@types/node": "18.7.14",
3233
"@types/object-hash": "2.2.1",
3334
"@types/pg": "8.6.5",
@@ -45,6 +46,8 @@
4546
},
4647
"dependencies": {
4748
"async-mutex": "0.4.0",
49+
"birpc": "0.2.17",
50+
"lodash": "4.17.21",
4851
"nanoid": "4.0.0",
4952
"object-hash": "3.0.0",
5053
"pg": "8.8.0"
@@ -59,5 +62,8 @@
5962
"build": "tsup",
6063
"format": "prettier -w .",
6164
"format:check": "prettier --check ."
65+
},
66+
"engines": {
67+
"node": ">=18"
6268
}
6369
}

src/index.ts

Lines changed: 134 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,57 @@
1-
import { registerSharedWorker, SharedWorker } from "ava/plugin"
1+
import { registerSharedWorker } from "ava/plugin"
22
import hash from "object-hash"
33
import path from "node:path"
4-
import {
4+
import type {
55
ConnectionDetailsFromWorker,
6-
FinishedRunningBeforeTemplateIsBakedHookMessage,
76
InitialWorkerData,
8-
MessageFromWorker,
9-
MessageToWorker,
7+
SharedWorkerFunctions,
8+
TestWorkerFunctions,
109
} from "./internal-types"
11-
import {
10+
import type {
1211
ConnectionDetails,
1312
GetTestPostgresDatabase,
1413
GetTestPostgresDatabaseFactoryOptions,
1514
GetTestPostgresDatabaseOptions,
16-
GetTestPostgresDatabaseResult,
1715
} from "./public-types"
1816
import { Pool } from "pg"
19-
import { Jsonifiable } from "type-fest"
20-
import { StartedNetwork } from "testcontainers"
21-
import { ExecutionContext } from "ava"
17+
import type { Jsonifiable } from "type-fest"
18+
import type { ExecutionContext } from "ava"
19+
import { once } from "node:events"
20+
import { createBirpc } from "birpc"
21+
import { ExecResult } from "testcontainers"
22+
import isPlainObject from "lodash/isPlainObject"
23+
24+
// https://stackoverflow.com/a/30580513
25+
const isSerializable = (obj: Record<any, any>): boolean => {
26+
var isNestedSerializable
27+
function isPlain(val: any) {
28+
return (
29+
typeof val === "undefined" ||
30+
typeof val === "string" ||
31+
typeof val === "boolean" ||
32+
typeof val === "number" ||
33+
Array.isArray(val) ||
34+
isPlainObject(val)
35+
)
36+
}
37+
if (!isPlain(obj)) {
38+
return false
39+
}
40+
for (var property in obj) {
41+
if (obj.hasOwnProperty(property)) {
42+
if (!isPlain(obj[property])) {
43+
return false
44+
}
45+
if (typeof obj[property] == "object") {
46+
isNestedSerializable = isSerializable(obj[property])
47+
if (!isNestedSerializable) {
48+
return false
49+
}
50+
}
51+
}
52+
}
53+
return true
54+
}
2255

2356
const getWorker = async (
2457
initialData: InitialWorkerData,
@@ -50,6 +83,24 @@ const getWorker = async (
5083
})
5184
}
5285

86+
const teardownConnection = async ({
87+
pool,
88+
pgbouncerPool,
89+
}: ConnectionDetails) => {
90+
try {
91+
await pool.end()
92+
await pgbouncerPool?.end()
93+
} catch (error) {
94+
if (
95+
(error as Error).message.includes("Called end on pool more than once")
96+
) {
97+
return
98+
}
99+
100+
throw error
101+
}
102+
}
103+
53104
export const getTestPostgresDatabaseFactory = <
54105
Params extends Jsonifiable = never
55106
>(
@@ -63,71 +114,34 @@ export const getTestPostgresDatabaseFactory = <
63114

64115
const workerPromise = getWorker(initialData, options as any)
65116

66-
const getTestPostgresDatabase: GetTestPostgresDatabase<Params> = async (
67-
t: ExecutionContext,
68-
params: any,
69-
getTestDatabaseOptions?: GetTestPostgresDatabaseOptions
70-
) => {
71-
const mapWorkerConnectionDetailsToConnectionDetails = (
72-
connectionDetailsFromWorker: ConnectionDetailsFromWorker
73-
): ConnectionDetails => {
74-
const pool = new Pool({
75-
connectionString: connectionDetailsFromWorker.connectionString,
76-
})
77-
78-
let pgbouncerPool: Pool | undefined
79-
if (connectionDetailsFromWorker.pgbouncerConnectionString) {
80-
pgbouncerPool = new Pool({
81-
connectionString:
82-
connectionDetailsFromWorker.pgbouncerConnectionString,
83-
})
84-
}
85-
86-
t.teardown(async () => {
87-
try {
88-
await pool.end()
89-
await pgbouncerPool?.end()
90-
} catch (error) {
91-
if (
92-
(error as Error).message.includes(
93-
"Called end on pool more than once"
94-
)
95-
) {
96-
return
97-
}
117+
const mapWorkerConnectionDetailsToConnectionDetails = (
118+
connectionDetailsFromWorker: ConnectionDetailsFromWorker
119+
): ConnectionDetails => {
120+
const pool = new Pool({
121+
connectionString: connectionDetailsFromWorker.connectionString,
122+
})
98123

99-
throw error
100-
}
124+
let pgbouncerPool: Pool | undefined
125+
if (connectionDetailsFromWorker.pgbouncerConnectionString) {
126+
pgbouncerPool = new Pool({
127+
connectionString: connectionDetailsFromWorker.pgbouncerConnectionString,
101128
})
102-
103-
return {
104-
...connectionDetailsFromWorker,
105-
pool,
106-
pgbouncerPool,
107-
}
108129
}
109130

110-
const worker = await workerPromise
111-
await worker.available
112-
113-
const waitForAndHandleReply = async (
114-
message: SharedWorker.Plugin.PublishedMessage
115-
): Promise<GetTestPostgresDatabaseResult> => {
116-
let reply = await message.replies().next()
117-
const replyData: MessageFromWorker = reply.value.data
118-
119-
if (replyData.type === "RUN_HOOK_BEFORE_TEMPLATE_IS_BAKED") {
120-
let result: FinishedRunningBeforeTemplateIsBakedHookMessage["result"] =
121-
{
122-
status: "success",
123-
result: undefined,
124-
}
131+
return {
132+
...connectionDetailsFromWorker,
133+
pool,
134+
pgbouncerPool,
135+
}
136+
}
125137

138+
let rpcCallback: (data: any) => void
139+
const rpc = createBirpc<SharedWorkerFunctions, TestWorkerFunctions>(
140+
{
141+
runBeforeTemplateIsBakedHook: async (connection, params) => {
126142
if (options?.beforeTemplateIsBaked) {
127143
const connectionDetails =
128-
mapWorkerConnectionDetailsToConnectionDetails(
129-
replyData.connectionDetails
130-
)
144+
mapWorkerConnectionDetailsToConnectionDetails(connection)
131145

132146
// Ignore if the pool is terminated by the shared worker
133147
// (This happens in CI for some reason even though we drain the pool first.)
@@ -143,94 +157,70 @@ export const getTestPostgresDatabaseFactory = <
143157
throw error
144158
})
145159

146-
try {
147-
const hookResult = await options.beforeTemplateIsBaked({
148-
params,
149-
connection: connectionDetails,
150-
containerExec: async (command) => {
151-
const request = reply.value.reply({
152-
type: "EXEC_COMMAND_IN_CONTAINER",
153-
command,
154-
})
155-
156-
reply = await request.replies().next()
157-
158-
if (
159-
reply.value.data.type !== "EXEC_COMMAND_IN_CONTAINER_RESULT"
160-
) {
161-
throw new Error(
162-
"Expected EXEC_COMMAND_IN_CONTAINER_RESULT message"
163-
)
164-
}
165-
166-
return reply.value.data.result
167-
},
168-
})
169-
170-
result = {
171-
status: "success",
172-
result: hookResult,
173-
}
174-
} catch (error) {
175-
result = {
176-
status: "error",
177-
error:
178-
error instanceof Error
179-
? error.stack ?? error.message
180-
: new Error(
181-
"Unknown error type thrown in beforeTemplateIsBaked hook"
182-
),
183-
}
184-
} finally {
185-
// Otherwise connection will be killed by worker when converting to template
186-
await connectionDetails.pool.end()
187-
}
188-
}
160+
const hookResult = await options.beforeTemplateIsBaked({
161+
params: params as any,
162+
connection: connectionDetails,
163+
containerExec: async (command): Promise<ExecResult> =>
164+
rpc.execCommandInContainer(command),
165+
})
189166

190-
try {
191-
return waitForAndHandleReply(
192-
reply.value.reply({
193-
type: "FINISHED_RUNNING_HOOK_BEFORE_TEMPLATE_IS_BAKED",
194-
result,
195-
} as MessageToWorker)
196-
)
197-
} catch (error) {
198-
if (error instanceof Error && error.name === "DataCloneError") {
167+
await teardownConnection(connectionDetails)
168+
169+
if (hookResult && !isSerializable(hookResult)) {
199170
throw new TypeError(
200171
"Return value of beforeTemplateIsBaked() hook could not be serialized. Make sure it returns only JSON-serializable values."
201172
)
202173
}
203174

204-
throw error
205-
}
206-
} else if (replyData.type === "GOT_DATABASE") {
207-
if (replyData.beforeTemplateIsBakedResult.status === "error") {
208-
if (typeof replyData.beforeTemplateIsBakedResult.error === "string") {
209-
throw new Error(replyData.beforeTemplateIsBakedResult.error)
210-
}
211-
212-
throw replyData.beforeTemplateIsBakedResult.error
175+
return hookResult
213176
}
177+
},
178+
},
179+
{
180+
post: async (data) => {
181+
const worker = await workerPromise
182+
await worker.available
183+
worker.publish(data)
184+
},
185+
on: (data) => {
186+
rpcCallback = data
187+
},
188+
}
189+
)
214190

215-
return {
216-
...mapWorkerConnectionDetailsToConnectionDetails(
217-
replyData.connectionDetails
218-
),
219-
beforeTemplateIsBakedResult:
220-
replyData.beforeTemplateIsBakedResult.result,
221-
}
222-
}
191+
// Automatically cleaned up by AVA since each test file runs in a separate worker
192+
const _messageHandlerPromise = (async () => {
193+
const worker = await workerPromise
194+
await worker.available
223195

224-
throw new Error(`Unexpected message type: ${replyData.type}`)
196+
for await (const msg of worker.subscribe()) {
197+
rpcCallback!(msg.data)
225198
}
199+
})()
226200

227-
return waitForAndHandleReply(
228-
worker.publish({
229-
type: "GET_TEST_DATABASE",
230-
params,
231-
key: getTestDatabaseOptions?.databaseDedupeKey,
232-
} as MessageToWorker)
201+
const getTestPostgresDatabase: GetTestPostgresDatabase<Params> = async (
202+
t: ExecutionContext,
203+
params: any,
204+
getTestDatabaseOptions?: GetTestPostgresDatabaseOptions
205+
) => {
206+
const testDatabaseConnection = await rpc.getTestDatabase({
207+
databaseDedupeKey: getTestDatabaseOptions?.databaseDedupeKey,
208+
params,
209+
})
210+
211+
const connectionDetails = mapWorkerConnectionDetailsToConnectionDetails(
212+
testDatabaseConnection.connectionDetails
233213
)
214+
215+
t.teardown(async () => {
216+
await teardownConnection(connectionDetails)
217+
})
218+
219+
return {
220+
...connectionDetails,
221+
beforeTemplateIsBakedResult:
222+
testDatabaseConnection.beforeTemplateIsBakedResult,
223+
}
234224
}
235225

236226
return getTestPostgresDatabase

0 commit comments

Comments
 (0)