@shivaedev/effect-pg-boss 0.8.1 → 0.10.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 +13 -0
- package/README.md +75 -24
- package/dist/server/error.d.ts +10 -1
- package/dist/server/error.d.ts.map +1 -1
- package/dist/server/error.js +2 -0
- package/dist/server/error.js.map +1 -1
- package/dist/server/internal/expireAttempt.d.ts +3 -0
- package/dist/server/internal/expireAttempt.d.ts.map +1 -0
- package/dist/server/internal/expireAttempt.js +14 -0
- package/dist/server/internal/expireAttempt.js.map +1 -0
- package/dist/server/internal/observeTestingSettlements.d.ts +6 -0
- package/dist/server/internal/observeTestingSettlements.d.ts.map +1 -0
- package/dist/server/internal/observeTestingSettlements.js +35 -0
- package/dist/server/internal/observeTestingSettlements.js.map +1 -0
- package/dist/server/internal/runSchemaPlan.d.ts +7 -0
- package/dist/server/internal/runSchemaPlan.d.ts.map +1 -0
- package/dist/server/internal/runSchemaPlan.js +25 -0
- package/dist/server/internal/runSchemaPlan.js.map +1 -0
- package/dist/server/internal/schemaIndexBuilds.d.ts +4 -0
- package/dist/server/internal/schemaIndexBuilds.d.ts.map +1 -0
- package/dist/server/internal/schemaIndexBuilds.js +34 -0
- package/dist/server/internal/schemaIndexBuilds.js.map +1 -0
- package/dist/server/internal/testingClient.d.ts +10 -0
- package/dist/server/internal/testingClient.d.ts.map +1 -0
- package/dist/server/internal/testingClient.js +101 -0
- package/dist/server/internal/testingClient.js.map +1 -0
- package/dist/server/internal/testingExpirations.d.ts +6 -0
- package/dist/server/internal/testingExpirations.d.ts.map +1 -0
- package/dist/server/internal/testingExpirations.js +27 -0
- package/dist/server/internal/testingExpirations.js.map +1 -0
- package/dist/server/internal/testingReadout.d.ts +44 -0
- package/dist/server/internal/testingReadout.d.ts.map +1 -0
- package/dist/server/internal/testingReadout.js +13 -0
- package/dist/server/internal/testingReadout.js.map +1 -0
- package/dist/server/internal/testingSignal.d.ts +7 -0
- package/dist/server/internal/testingSignal.d.ts.map +1 -0
- package/dist/server/internal/testingSignal.js +27 -0
- package/dist/server/internal/testingSignal.js.map +1 -0
- package/dist/server/schema.d.ts +8 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +43 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/testing.d.ts +61 -0
- package/dist/server/testing.d.ts.map +1 -0
- package/dist/server/testing.js +69 -0
- package/dist/server/testing.js.map +1 -0
- package/package.json +7 -8
- package/src/server/error.ts +7 -1
- package/src/server/internal/expireAttempt.ts +18 -0
- package/src/server/internal/observeTestingSettlements.ts +36 -0
- package/src/server/internal/runSchemaPlan.ts +32 -0
- package/src/server/internal/schemaIndexBuilds.ts +44 -0
- package/src/server/internal/testingClient.ts +115 -0
- package/src/server/internal/testingExpirations.ts +31 -0
- package/src/server/internal/testingReadout.ts +26 -0
- package/src/server/internal/testingSignal.ts +28 -0
- package/src/server/schema.ts +57 -0
- package/src/server/testing.ts +85 -0
- package/docs/north-star.md +0 -48
- package/docs/roadmap.md +0 -35
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { FindJobsOptions, PgBoss } from "pg-boss";
|
|
3
|
+
import type { JobPayloadSchema, QueueDefinition } from "#definition.ts";
|
|
4
|
+
import { PgBossPayloadError, PgBossTestingError } from "#error.ts";
|
|
5
|
+
export declare function testingOperation<A>(operation: string, run: () => Promise<A>, queue?: string): Effect.Effect<A, PgBossTestingError, never>;
|
|
6
|
+
export declare function testingReadout(client: () => PgBoss): <TName extends string, TPayload extends JobPayloadSchema>(queue: QueueDefinition<TName, TPayload>, options?: FindJobsOptions) => Effect.Effect<{
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
retryCount: number;
|
|
10
|
+
signal: AbortSignal;
|
|
11
|
+
groupId?: string | null;
|
|
12
|
+
groupTier?: string | null;
|
|
13
|
+
priority: number;
|
|
14
|
+
state: 'created' | 'retry' | 'active' | 'completed' | 'cancelled' | 'failed';
|
|
15
|
+
retryLimit: number;
|
|
16
|
+
retryDelay: number;
|
|
17
|
+
retryBackoff: boolean;
|
|
18
|
+
retryDelayMax?: number;
|
|
19
|
+
startAfter: Date;
|
|
20
|
+
startedOn: Date;
|
|
21
|
+
singletonKey: string | null;
|
|
22
|
+
singletonOn: Date | null;
|
|
23
|
+
expireInSeconds: number;
|
|
24
|
+
deleteAfterSeconds: number;
|
|
25
|
+
createdOn: Date;
|
|
26
|
+
completedOn: Date | null;
|
|
27
|
+
keepUntil: Date;
|
|
28
|
+
policy: import("pg-boss").QueuePolicy;
|
|
29
|
+
heartbeatOn: Date | null;
|
|
30
|
+
heartbeatSeconds: number | null;
|
|
31
|
+
blocked: boolean;
|
|
32
|
+
blocking: boolean;
|
|
33
|
+
pendingDependencies: number;
|
|
34
|
+
deadLetter: string;
|
|
35
|
+
output: object;
|
|
36
|
+
sourceName: string | null;
|
|
37
|
+
sourceId: string | null;
|
|
38
|
+
sourceCreatedOn: Date | null;
|
|
39
|
+
sourceRetryCount: number | null;
|
|
40
|
+
sourceOutput: object | null;
|
|
41
|
+
sourceRootId: string | null;
|
|
42
|
+
data: TPayload["Type"];
|
|
43
|
+
}[], PgBossPayloadError | PgBossTestingError, TPayload["DecodingServices"]>;
|
|
44
|
+
//# sourceMappingURL=testingReadout.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testingReadout.d.ts","sourceRoot":"","sources":["../../../src/server/internal/testingReadout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAoB,MAAM,QAAQ,CAAC;AAClD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACxE,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAGnE,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,MAAM,+CAK3F;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,MAAM,IAC1C,KAAK,SAAS,MAAM,EAAE,QAAQ,SAAS,gBAAgB,SAAS,eAAe,CAAC,KAAK,EAAE,QAAQ,CAAC,YAAY,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4EAWnI"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Effect, Redacted, Schema } from "effect";
|
|
2
|
+
import { PgBossPayloadError, PgBossTestingError } from "#error.ts";
|
|
3
|
+
import { payloadOf } from "#internal/tracing.ts";
|
|
4
|
+
export function testingOperation(operation, run, queue) {
|
|
5
|
+
return Effect.tryPromise({
|
|
6
|
+
catch: (original) => new PgBossTestingError({ operation, original: Redacted.make(original), queue }),
|
|
7
|
+
try: run,
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export function testingReadout(client) {
|
|
11
|
+
return (queue, options) => testingOperation("jobs", () => client().findJobs(queue.name, options), queue.name).pipe(Effect.flatMap((jobs) => Effect.forEach(jobs, (job) => Schema.decodeUnknownEffect(queue.schema)(payloadOf(job.data)).pipe(Effect.mapError((original) => new PgBossPayloadError({ direction: "decode", original: Redacted.make(original), queue: queue.name })), Effect.map((data) => ({ ...job, data }))))));
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=testingReadout.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testingReadout.js","sourceRoot":"","sources":["../../../src/server/internal/testingReadout.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGlD,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEjD,MAAM,UAAU,gBAAgB,CAAI,SAAiB,EAAE,GAAqB,EAAE,KAAc;IAC3F,OAAO,MAAM,CAAC,UAAU,CAAC;QACxB,KAAK,EAAE,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,kBAAkB,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,CAAC;QACpG,GAAG,EAAE,GAAG;KACR,CAAC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAAoB;IAClD,OAAO,CAA0D,KAAuC,EAAE,OAAyB,EAAE,EAAE,CACtI,gBAAgB,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAU,KAAK,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAC/F,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CACvB,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,GAAG,EAAE,EAAE,CAC5B,MAAM,CAAC,mBAAmB,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CACjE,MAAM,CAAC,QAAQ,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,kBAAkB,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,EACpI,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC,CACxC,CACD,CACD,CACD,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testingSignal.d.ts","sourceRoot":"","sources":["../../../src/server/internal/testingSignal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,wBAAgB,aAAa;IAI3B,OAAO;IAOP,QAAQ;IACR,aAAa,UAAU,MAAM,KAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC;EAatD"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
export function testingSignal() {
|
|
3
|
+
let revision = 0;
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
return {
|
|
6
|
+
changed() {
|
|
7
|
+
revision += 1;
|
|
8
|
+
for (const listener of listeners) {
|
|
9
|
+
listener(revision);
|
|
10
|
+
}
|
|
11
|
+
listeners.clear();
|
|
12
|
+
},
|
|
13
|
+
revision: Effect.sync(() => revision),
|
|
14
|
+
waitForChange: (after) => Effect.callback((resume) => {
|
|
15
|
+
if (revision !== after) {
|
|
16
|
+
resume(Effect.succeed(revision));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
function listener(current) {
|
|
20
|
+
resume(Effect.succeed(current));
|
|
21
|
+
}
|
|
22
|
+
listeners.add(listener);
|
|
23
|
+
return Effect.sync(() => listeners.delete(listener));
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=testingSignal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testingSignal.js","sourceRoot":"","sources":["../../../src/server/internal/testingSignal.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,UAAU,aAAa;IAC5B,IAAI,QAAQ,GAAG,CAAC,CAAC;IACjB,MAAM,SAAS,GAAG,IAAI,GAAG,EAA8B,CAAC;IACxD,OAAO;QACN,OAAO;YACN,QAAQ,IAAI,CAAC,CAAC;YACd,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;gBAClC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YACpB,CAAC;YACD,SAAS,CAAC,KAAK,EAAE,CAAC;QACnB,CAAC;QACD,QAAQ,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC;QACrC,aAAa,EAAE,CAAC,KAAa,EAAyB,EAAE,CACvD,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,EAAE,EAAE;YAC1B,IAAI,QAAQ,KAAK,KAAK,EAAE,CAAC;gBACxB,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;gBACjC,OAAO;YACR,CAAC;YACD,SAAS,QAAQ,CAAC,OAAe;gBAChC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC;YACjC,CAAC;YACD,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YACxB,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;QACtD,CAAC,CAAC;KACH,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { SqlClient } from "effect/sql";
|
|
3
|
+
import { type PgBossError } from "#error.ts";
|
|
4
|
+
export interface PgBossSchemaOptions {
|
|
5
|
+
readonly schema?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function installPgBossSchema(options?: PgBossSchemaOptions): Effect.Effect<void, PgBossError, SqlClient.SqlClient>;
|
|
8
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../../src/server/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAIvC,OAAO,EAAE,KAAK,WAAW,EAAiB,MAAM,WAAW,CAAC;AAI5D,MAAM,WAAW,mBAAmB;IACnC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAoCD,wBAAgB,mBAAmB,CAAC,OAAO,GAAE,mBAAwB,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,WAAW,EAAE,SAAS,CAAC,SAAS,CAAC,CAS5H"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { SqlClient } from "effect/sql";
|
|
3
|
+
import { getConstructionPlans, getMigrationPlans, PgBoss } from "pg-boss";
|
|
4
|
+
import pgBossPackage from "pg-boss/package.json" with { type: "json" };
|
|
5
|
+
import { toPgBossError } from "#error.ts";
|
|
6
|
+
import { runSchemaPlan } from "#internal/runSchemaPlan.ts";
|
|
7
|
+
import { resumeSchemaIndexes } from "#internal/schemaIndexBuilds.ts";
|
|
8
|
+
async function installSchema(connection, options, run) {
|
|
9
|
+
const schema = options.schema ?? "pgboss";
|
|
10
|
+
const construction = getConstructionPlans(schema);
|
|
11
|
+
const client = new PgBoss({
|
|
12
|
+
db: { executeSql: async (text, values = []) => ({ rows: Array.from(await run(connection.executeUnprepared(text, values, undefined))) }) },
|
|
13
|
+
schema,
|
|
14
|
+
});
|
|
15
|
+
if (!(await client.isInstalled())) {
|
|
16
|
+
await runSchemaPlan(connection, construction, run);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const version = await client.schemaVersion();
|
|
20
|
+
if (version === null) {
|
|
21
|
+
throw new Error("The pg-boss version table is empty");
|
|
22
|
+
}
|
|
23
|
+
if (version > pgBossPackage.pgboss.schema) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
if (version >= 27) {
|
|
27
|
+
await resumeSchemaIndexes(client, schema);
|
|
28
|
+
}
|
|
29
|
+
if (version < pgBossPackage.pgboss.schema) {
|
|
30
|
+
const partitionTables = Array.from(await run(connection.executeUnprepared(`SELECT table_name AS "tableName", policy FROM ${schema}.queue WHERE partition = true`, [], undefined)));
|
|
31
|
+
await runSchemaPlan(connection, getMigrationPlans(schema, version, { partitionTables }), run, { schema, version: pgBossPackage.pgboss.schema });
|
|
32
|
+
await resumeSchemaIndexes(client, schema);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function installPgBossSchema(options = {}) {
|
|
36
|
+
return Effect.scoped(Effect.gen(function* () {
|
|
37
|
+
const sql = yield* SqlClient.SqlClient;
|
|
38
|
+
const connection = yield* sql.reserve;
|
|
39
|
+
const run = Effect.runPromiseWith(yield* Effect.context());
|
|
40
|
+
yield* Effect.tryPromise({ catch: (error) => error, try: () => installSchema(connection, options, run) });
|
|
41
|
+
}).pipe(Effect.mapError((error) => toPgBossError("install", error)))).pipe(Effect.uninterruptible);
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/server/schema.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { PgBossClientFactory } from "#client.ts";
|
|
3
|
+
import type { QueueDefinition } from "#definition.ts";
|
|
4
|
+
import { PgBossTestingError } from "#error.ts";
|
|
5
|
+
export declare const testRetryPolicy: Readonly<{
|
|
6
|
+
retryBackoff: false;
|
|
7
|
+
retryDelay: 0;
|
|
8
|
+
}>;
|
|
9
|
+
export interface PgBossTestingOptions {
|
|
10
|
+
readonly retryPolicy?: typeof testRetryPolicy;
|
|
11
|
+
}
|
|
12
|
+
export declare function makePgBossTesting(options?: PgBossTestingOptions): {
|
|
13
|
+
checkBatchWindow: (queue: QueueDefinition, millisecondsPerJob: number) => Effect.Effect<void, PgBossTestingError, never>;
|
|
14
|
+
checkSchemaVersion: (expected: number) => Effect.Effect<void, PgBossTestingError, never>;
|
|
15
|
+
clientFactory: PgBossClientFactory;
|
|
16
|
+
expireAfter: (queue: QueueDefinition, attempts: number) => Effect.Effect<void, PgBossTestingError, never>;
|
|
17
|
+
failNextSend: (queue: QueueDefinition) => Effect.Effect<void, never, never>;
|
|
18
|
+
jobs: <TName extends string, TPayload extends import("#definition.ts").JobPayloadSchema>(queue: QueueDefinition<TName, TPayload>, options?: import("pg-boss").FindJobsOptions) => Effect.Effect<{
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
retryCount: number;
|
|
22
|
+
signal: AbortSignal;
|
|
23
|
+
groupId?: string | null;
|
|
24
|
+
groupTier?: string | null;
|
|
25
|
+
priority: number;
|
|
26
|
+
state: 'created' | 'retry' | 'active' | 'completed' | 'cancelled' | 'failed';
|
|
27
|
+
retryLimit: number;
|
|
28
|
+
retryDelay: number;
|
|
29
|
+
retryBackoff: boolean;
|
|
30
|
+
retryDelayMax?: number;
|
|
31
|
+
startAfter: Date;
|
|
32
|
+
startedOn: Date;
|
|
33
|
+
singletonKey: string | null;
|
|
34
|
+
singletonOn: Date | null;
|
|
35
|
+
expireInSeconds: number;
|
|
36
|
+
deleteAfterSeconds: number;
|
|
37
|
+
createdOn: Date;
|
|
38
|
+
completedOn: Date | null;
|
|
39
|
+
keepUntil: Date;
|
|
40
|
+
policy: import("pg-boss").QueuePolicy;
|
|
41
|
+
heartbeatOn: Date | null;
|
|
42
|
+
heartbeatSeconds: number | null;
|
|
43
|
+
blocked: boolean;
|
|
44
|
+
blocking: boolean;
|
|
45
|
+
pendingDependencies: number;
|
|
46
|
+
deadLetter: string;
|
|
47
|
+
output: object;
|
|
48
|
+
sourceName: string | null;
|
|
49
|
+
sourceId: string | null;
|
|
50
|
+
sourceCreatedOn: Date | null;
|
|
51
|
+
sourceRetryCount: number | null;
|
|
52
|
+
sourceOutput: object | null;
|
|
53
|
+
sourceRootId: string | null;
|
|
54
|
+
data: TPayload["Type"];
|
|
55
|
+
}[], import("#error.ts").PgBossPayloadError | PgBossTestingError, TPayload["DecodingServices"]>;
|
|
56
|
+
revision: Effect.Effect<number, never, never>;
|
|
57
|
+
schemaVersion: Effect.Effect<number | null, PgBossTestingError, never>;
|
|
58
|
+
waitForChange: (after: number) => Effect.Effect<number>;
|
|
59
|
+
};
|
|
60
|
+
export type PgBossTesting = ReturnType<typeof makePgBossTesting>;
|
|
61
|
+
//# sourceMappingURL=testing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../../src/server/testing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAY,MAAM,QAAQ,CAAC;AAE1C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAK/C,eAAO,MAAM,eAAe;;;EAAwD,CAAC;AAErF,MAAM,WAAW,oBAAoB;IACpC,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,eAAe,CAAC;CAC9C;AAED,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,oBAAyB;8BAuBxC,eAAe,sBAAsB,MAAM;mCAqBtC,MAAM;;yBAOhB,eAAe,YAAY,MAAM;0BAUhC,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAMtC;AAED,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Effect, Redacted } from "effect";
|
|
2
|
+
import { PgBoss } from "pg-boss";
|
|
3
|
+
import { PgBossTestingError } from "#error.ts";
|
|
4
|
+
import { testingClient } from "#internal/testingClient.ts";
|
|
5
|
+
import { testingOperation, testingReadout } from "#internal/testingReadout.ts";
|
|
6
|
+
import { testingSignal } from "#internal/testingSignal.ts";
|
|
7
|
+
export const testRetryPolicy = Object.freeze({ retryBackoff: false, retryDelay: 0 });
|
|
8
|
+
export function makePgBossTesting(options = {}) {
|
|
9
|
+
let current;
|
|
10
|
+
const signal = testingSignal();
|
|
11
|
+
const controls = {
|
|
12
|
+
changed: signal.changed,
|
|
13
|
+
expirations: new Map(),
|
|
14
|
+
failures: new Set(),
|
|
15
|
+
retryPolicy: options.retryPolicy ?? {},
|
|
16
|
+
};
|
|
17
|
+
function client() {
|
|
18
|
+
if (current === undefined) {
|
|
19
|
+
throw new Error("Provide the testing clientFactory to a jobs Layer first");
|
|
20
|
+
}
|
|
21
|
+
return current;
|
|
22
|
+
}
|
|
23
|
+
const clientFactory = (clientOptions) => {
|
|
24
|
+
if (current !== undefined) {
|
|
25
|
+
throw new Error("A testing controller owns one jobs client; create a controller per runtime");
|
|
26
|
+
}
|
|
27
|
+
current = new PgBoss({ ...clientOptions, "__test__enableSpies": true });
|
|
28
|
+
return testingClient(current, clientOptions.schema ?? "pgboss", controls);
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
checkBatchWindow: (queue, millisecondsPerJob) => testingOperation("checkBatchWindow", async () => {
|
|
32
|
+
const stored = await client().getQueue(queue.name);
|
|
33
|
+
if (stored === null) {
|
|
34
|
+
throw new Error("Queue is not registered");
|
|
35
|
+
}
|
|
36
|
+
if (stored.expireInSeconds === undefined) {
|
|
37
|
+
throw new Error("Queue has no expiry window");
|
|
38
|
+
}
|
|
39
|
+
if (!Number.isFinite(millisecondsPerJob) || millisecondsPerJob < 0) {
|
|
40
|
+
throw new RangeError("Job duration must be nonnegative and finite");
|
|
41
|
+
}
|
|
42
|
+
const required = (queue.workerOptions.batchSize ?? 10) * millisecondsPerJob;
|
|
43
|
+
if (required >= stored.expireInSeconds * 1000) {
|
|
44
|
+
throw new Error("Sequential batch work must fit within the queue expiry window");
|
|
45
|
+
}
|
|
46
|
+
}, queue.name),
|
|
47
|
+
checkSchemaVersion: (expected) => testingOperation("checkSchemaVersion", async () => {
|
|
48
|
+
if ((await client().schemaVersion()) !== expected) {
|
|
49
|
+
throw new Error(`Expected pg-boss schema version ${expected}`);
|
|
50
|
+
}
|
|
51
|
+
}),
|
|
52
|
+
clientFactory,
|
|
53
|
+
expireAfter: (queue, attempts) => Effect.try({
|
|
54
|
+
catch: (original) => new PgBossTestingError({ operation: "expireAfter", original: Redacted.make(original), queue: queue.name }),
|
|
55
|
+
try: () => {
|
|
56
|
+
if (!Number.isSafeInteger(attempts) || attempts < 1) {
|
|
57
|
+
throw new RangeError("Attempts must be a positive integer");
|
|
58
|
+
}
|
|
59
|
+
controls.expirations.set(queue.name, attempts);
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
failNextSend: (queue) => Effect.sync(() => controls.failures.add(queue.name)).pipe(Effect.asVoid),
|
|
63
|
+
jobs: testingReadout(client),
|
|
64
|
+
revision: signal.revision,
|
|
65
|
+
schemaVersion: testingOperation("schemaVersion", () => client().schemaVersion()),
|
|
66
|
+
waitForChange: signal.waitForChange,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../../src/server/testing.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/effect-pg-boss",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Effect-native jobs and lifecycle management for pg-boss",
|
|
5
5
|
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/effect-pg-boss#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -24,9 +24,8 @@
|
|
|
24
24
|
},
|
|
25
25
|
"exports": {
|
|
26
26
|
"./*.ts": {
|
|
27
|
-
"types": "./dist/server/*.d.ts",
|
|
28
27
|
"source": "./src/server/*.ts",
|
|
29
|
-
"
|
|
28
|
+
"types": "./dist/server/*.d.ts",
|
|
30
29
|
"default": "./dist/server/*.js"
|
|
31
30
|
},
|
|
32
31
|
"./internal/*": null,
|
|
@@ -38,18 +37,18 @@
|
|
|
38
37
|
"!src/**/*.test.ts",
|
|
39
38
|
"!src/**/*.spec.ts",
|
|
40
39
|
"!src/**/test-support",
|
|
41
|
-
"docs",
|
|
42
40
|
"CHANGELOG.md",
|
|
43
41
|
"README.md"
|
|
44
42
|
],
|
|
45
43
|
"devDependencies": {
|
|
46
|
-
"@shivaedev/effect-test
|
|
47
|
-
"@shivaedev/
|
|
44
|
+
"@shivaedev/effect-test": "0.6.0",
|
|
45
|
+
"@shivaedev/effect-test-sql": "0.5.5",
|
|
46
|
+
"@shivaedev/quality": "0.27.0",
|
|
47
|
+
"@shivaedev/test-story": "0.9.0",
|
|
48
48
|
"@types/node": "24.19.2",
|
|
49
|
-
"@typescript/native": "npm:typescript@7.0.2",
|
|
50
49
|
"effect": "4.0.4",
|
|
51
50
|
"pg-boss": "12.37.1",
|
|
52
|
-
"typescript": "
|
|
51
|
+
"typescript": "7.0.2",
|
|
53
52
|
"vitest": "5.0.3"
|
|
54
53
|
},
|
|
55
54
|
"peerDependencies": {
|
package/src/server/error.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Data, Redacted } from "effect";
|
|
2
2
|
|
|
3
|
-
export type PgBossOperation = "counts" | "enqueue" | "getJob" | "health" | "register" | "retry" | "start" | "stop";
|
|
3
|
+
export type PgBossOperation = "counts" | "enqueue" | "getJob" | "health" | "install" | "register" | "retry" | "start" | "stop";
|
|
4
4
|
|
|
5
5
|
export class PgBossError extends Data.TaggedError("PgBossError")<{
|
|
6
6
|
readonly operation: PgBossOperation;
|
|
@@ -37,6 +37,12 @@ export class PgBossPayloadError extends Data.TaggedError("PgBossPayloadError")<{
|
|
|
37
37
|
readonly original: Redacted.Redacted<unknown>;
|
|
38
38
|
}> {}
|
|
39
39
|
|
|
40
|
+
export class PgBossTestingError extends Data.TaggedError("PgBossTestingError")<{
|
|
41
|
+
readonly operation: string;
|
|
42
|
+
readonly original: Redacted.Redacted<unknown>;
|
|
43
|
+
readonly queue?: string | undefined;
|
|
44
|
+
}> {}
|
|
45
|
+
|
|
40
46
|
export const toPgBossError = (operation: PgBossOperation, error: unknown, queue?: string): PgBossError =>
|
|
41
47
|
new PgBossError({
|
|
42
48
|
operation,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Job, PgBoss } from "pg-boss";
|
|
2
|
+
|
|
3
|
+
export async function expireAttempt(client: PgBoss, schema: string, job: Job<unknown>): Promise<void> {
|
|
4
|
+
if ((await client.schemaVersion()) !== 45) {
|
|
5
|
+
throw new Error("Testing expiry requires pg-boss schema version 45");
|
|
6
|
+
}
|
|
7
|
+
const quoted = `"${schema.replaceAll('"', '""')}"`;
|
|
8
|
+
await client.getDb().executeSql(
|
|
9
|
+
`UPDATE ${quoted}.job SET started_on = ${quoted}.job_now() - (expire_seconds + 1) * interval '1 second'
|
|
10
|
+
WHERE name = $1 AND id = $2 AND retry_count = $3 AND state = 'active'`,
|
|
11
|
+
[job.name, job.id, job.retryCount],
|
|
12
|
+
);
|
|
13
|
+
// Supervision owns expiry and dead lettering; make its claim due with the job's deadline.
|
|
14
|
+
await client
|
|
15
|
+
.getDb()
|
|
16
|
+
.executeSql(`UPDATE ${quoted}.queue SET monitor_claim_on = ${quoted}.job_now() - interval '1 week' WHERE name = $1`, [job.name]);
|
|
17
|
+
await client.supervise(job.name);
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PgBoss } from "pg-boss";
|
|
2
|
+
|
|
3
|
+
export function observeTestingSettlements(client: PgBoss, changed: () => void) {
|
|
4
|
+
const observers = new Map<string, () => void>();
|
|
5
|
+
return {
|
|
6
|
+
close() {
|
|
7
|
+
for (const close of observers.values()) {
|
|
8
|
+
close();
|
|
9
|
+
}
|
|
10
|
+
observers.clear();
|
|
11
|
+
},
|
|
12
|
+
watch(name: string) {
|
|
13
|
+
if (observers.has(name)) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const spy = client.getSpy(name);
|
|
17
|
+
let open = true;
|
|
18
|
+
async function observe() {
|
|
19
|
+
while (true) {
|
|
20
|
+
await Promise.race([spy.waitForJob(() => true, "completed"), spy.waitForJob(() => true, "failed")]);
|
|
21
|
+
if (!open) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// This controller owns the spy; clear history and the losing wait before rearming both outcomes.
|
|
25
|
+
spy.clear();
|
|
26
|
+
changed();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
observers.set(name, () => {
|
|
30
|
+
open = false;
|
|
31
|
+
spy.clear();
|
|
32
|
+
});
|
|
33
|
+
void observe().catch((error: unknown) => client.emit("error", error instanceof Error ? error : new Error(String(error))));
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { Connection } from "effect/sql/SqlConnection";
|
|
3
|
+
import { pendingIndexInserts } from "./schemaIndexBuilds.ts";
|
|
4
|
+
|
|
5
|
+
export async function runSchemaPlan(
|
|
6
|
+
connection: Connection,
|
|
7
|
+
plan: string,
|
|
8
|
+
run: <A, E>(effect: Effect.Effect<A, E>) => Promise<A>,
|
|
9
|
+
upgrade?: { readonly schema: string; readonly version: number },
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
// Native exported plans append concurrent index DDL after the migration transaction.
|
|
12
|
+
const parts = /^\s*BEGIN;(?<body>[\s\S]*)\n {4}COMMIT;(?<concurrent>[\s\S]*)$/u.exec(plan);
|
|
13
|
+
if (parts?.groups === undefined) {
|
|
14
|
+
throw new Error("Unrecognized pg-boss schema plan transaction");
|
|
15
|
+
}
|
|
16
|
+
const { body = "", concurrent = "" } = parts.groups;
|
|
17
|
+
function quote(text: string) {
|
|
18
|
+
return `'${text.replaceAll("'", "''")}'`;
|
|
19
|
+
}
|
|
20
|
+
const commands = concurrent
|
|
21
|
+
.trim()
|
|
22
|
+
.split(/;\s*(?:\n|$)/u)
|
|
23
|
+
.filter((statement) => statement.trim() !== "");
|
|
24
|
+
const pending = upgrade === undefined ? "" : pendingIndexInserts(upgrade.schema, upgrade.version, commands, quote);
|
|
25
|
+
// Effect SQL uses the extended protocol, which accepts one statement per command.
|
|
26
|
+
await run(connection.executeRaw(`DO ${quote(`BEGIN EXECUTE ${quote(`${body}\n${pending}`)}; END`)}`, []));
|
|
27
|
+
if (upgrade === undefined) {
|
|
28
|
+
for (const statement of commands) {
|
|
29
|
+
await run(connection.executeRaw(statement, []));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { PgBoss } from "pg-boss";
|
|
2
|
+
|
|
3
|
+
const BUILD_PREFIX = "effect-pg-boss-schema:";
|
|
4
|
+
|
|
5
|
+
function indexName(command: string): string {
|
|
6
|
+
const match =
|
|
7
|
+
/^\s*(?:CREATE (?:UNIQUE )?INDEX CONCURRENTLY IF NOT EXISTS |DROP INDEX CONCURRENTLY IF EXISTS (?:(?:"[^"]+"|\w+)\.))(?<name>\w+)/mu.exec(
|
|
8
|
+
command,
|
|
9
|
+
);
|
|
10
|
+
const name = match?.groups?.name;
|
|
11
|
+
if (name === undefined) {
|
|
12
|
+
throw new Error("Unrecognized pg-boss concurrent index command");
|
|
13
|
+
}
|
|
14
|
+
return name;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function pendingIndexInserts(schema: string, version: number, commands: readonly string[], quote: (text: string) => string): string {
|
|
18
|
+
return commands
|
|
19
|
+
.map((command, position) => {
|
|
20
|
+
const table = indexName(command).replace(/_i\d+$/u, "");
|
|
21
|
+
const name = `${BUILD_PREFIX}${String(position).padStart(6, "0")}`;
|
|
22
|
+
return `INSERT INTO ${schema}.bam (name, version, table_name, command) VALUES (${quote(name)}, ${version}, ${quote(table)}, ${quote(command)});`;
|
|
23
|
+
})
|
|
24
|
+
.join("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function resumeSchemaIndexes(client: PgBoss, schema: string): Promise<void> {
|
|
28
|
+
const db = client.getDb();
|
|
29
|
+
const { rows } = await db.executeSql(
|
|
30
|
+
`SELECT id, command FROM ${schema}.bam WHERE starts_with(name, $1) AND status <> 'completed' ORDER BY created_on, name`,
|
|
31
|
+
[BUILD_PREFIX],
|
|
32
|
+
);
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
const name = indexName(row.command);
|
|
35
|
+
if (/^\s*CREATE /mu.test(row.command)) {
|
|
36
|
+
const probe = await db.executeSql("SELECT NOT indisvalid AS invalid FROM pg_index WHERE indexrelid = to_regclass($1)", [`${schema}.${name}`]);
|
|
37
|
+
if (probe.rows[0]?.invalid === true) {
|
|
38
|
+
await db.executeSql(`DROP INDEX CONCURRENTLY IF EXISTS ${schema}."${name}"`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
await db.executeSql(row.command);
|
|
42
|
+
await db.executeSql(`UPDATE ${schema}.bam SET status = 'completed', completed_on = clock_timestamp() WHERE id = $1`, [row.id]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { Job, PgBoss, SendOptions, UpdateOptions, UpdateRequest, UpdateResponse } from "pg-boss";
|
|
2
|
+
import type { PgBossClient } from "#client.ts";
|
|
3
|
+
import { expireAttempt } from "#internal/expireAttempt.ts";
|
|
4
|
+
import { observeTestingSettlements } from "#internal/observeTestingSettlements.ts";
|
|
5
|
+
import { testingExpirations } from "#internal/testingExpirations.ts";
|
|
6
|
+
|
|
7
|
+
export interface TestingControls {
|
|
8
|
+
readonly changed: () => void;
|
|
9
|
+
readonly expirations: Map<string, number>;
|
|
10
|
+
readonly failures: Set<string>;
|
|
11
|
+
readonly retryPolicy: Pick<SendOptions, "retryBackoff" | "retryDelay">;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function testingClient(client: PgBoss, schema: string, controls: TestingControls): PgBossClient {
|
|
15
|
+
const settlements = observeTestingSettlements(client, controls.changed);
|
|
16
|
+
const expirations = testingExpirations(client);
|
|
17
|
+
function update(request: UpdateRequest): Promise<UpdateResponse>;
|
|
18
|
+
function update(name: string, data: object | null | undefined, options?: UpdateOptions): Promise<UpdateResponse>;
|
|
19
|
+
async function update(request: string | UpdateRequest, data?: object | null, options?: UpdateOptions): Promise<UpdateResponse> {
|
|
20
|
+
const result = typeof request === "string" ? await client.update(request, data, options) : await client.update(request);
|
|
21
|
+
controls.changed();
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
const overrides: Partial<PgBossClient> = {
|
|
25
|
+
complete: async (...args) => {
|
|
26
|
+
const result = await client.complete(...args);
|
|
27
|
+
controls.changed();
|
|
28
|
+
return result;
|
|
29
|
+
},
|
|
30
|
+
createQueue: (name, options) => {
|
|
31
|
+
const { retryDelayMax, ...rest } = options ?? {};
|
|
32
|
+
return client.createQueue(name, {
|
|
33
|
+
...rest,
|
|
34
|
+
...(controls.retryPolicy.retryBackoff === false || retryDelayMax === undefined ? {} : { retryDelayMax }),
|
|
35
|
+
...controls.retryPolicy,
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
fail: async (...args) => {
|
|
39
|
+
const result = await client.fail(...args);
|
|
40
|
+
controls.changed();
|
|
41
|
+
return result;
|
|
42
|
+
},
|
|
43
|
+
retry: async (...args) => {
|
|
44
|
+
const result = await client.retry(...args);
|
|
45
|
+
controls.changed();
|
|
46
|
+
return result;
|
|
47
|
+
},
|
|
48
|
+
send: async (name, data, options) => {
|
|
49
|
+
if (controls.failures.delete(name)) {
|
|
50
|
+
throw new Error("Injected pg-boss send failure");
|
|
51
|
+
}
|
|
52
|
+
const attempts = controls.expirations.get(name);
|
|
53
|
+
const { retryDelayMax, ...rest } = options ?? {};
|
|
54
|
+
const id = await expirations.send(
|
|
55
|
+
name,
|
|
56
|
+
data,
|
|
57
|
+
{
|
|
58
|
+
...rest,
|
|
59
|
+
...(controls.retryPolicy.retryBackoff === false || retryDelayMax === undefined ? {} : { retryDelayMax }),
|
|
60
|
+
...controls.retryPolicy,
|
|
61
|
+
...(attempts === undefined ? {} : { retryLimit: attempts - 1 }),
|
|
62
|
+
},
|
|
63
|
+
attempts,
|
|
64
|
+
);
|
|
65
|
+
controls.changed();
|
|
66
|
+
return id;
|
|
67
|
+
},
|
|
68
|
+
stop: async (options) => {
|
|
69
|
+
try {
|
|
70
|
+
await client.stop(options);
|
|
71
|
+
} finally {
|
|
72
|
+
settlements.close();
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
update,
|
|
76
|
+
updateQueue: (name, options) =>
|
|
77
|
+
client.updateQueue(name, {
|
|
78
|
+
...options,
|
|
79
|
+
...controls.retryPolicy,
|
|
80
|
+
...(controls.retryPolicy.retryBackoff === false ? { retryDelayMax: null } : {}),
|
|
81
|
+
}),
|
|
82
|
+
work: (name, options, handler) => {
|
|
83
|
+
settlements.watch(name);
|
|
84
|
+
return client.work(name, options, async (jobs: Job<unknown>[]) => {
|
|
85
|
+
controls.changed();
|
|
86
|
+
try {
|
|
87
|
+
const ready: Job<unknown>[] = [];
|
|
88
|
+
for (const job of jobs) {
|
|
89
|
+
const attempts = await expirations.attemptsFor(job);
|
|
90
|
+
if (attempts !== undefined && job.retryCount + 1 >= attempts) {
|
|
91
|
+
await expireAttempt(client, schema, job);
|
|
92
|
+
} else {
|
|
93
|
+
ready.push(job);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (ready.length > 0) {
|
|
97
|
+
await handler(ready);
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
controls.changed();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
return new Proxy(client, {
|
|
106
|
+
get(target, property) {
|
|
107
|
+
if (Object.hasOwn(overrides, property)) {
|
|
108
|
+
const override: unknown = Reflect.get(overrides, property);
|
|
109
|
+
return override;
|
|
110
|
+
}
|
|
111
|
+
const value: unknown = Reflect.get(target, property);
|
|
112
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|