@anterior/brrr 0.1.1

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/src/app.ts ADDED
@@ -0,0 +1,201 @@
1
+ import {
2
+ type Connection,
3
+ Defer,
4
+ type DeferredCall,
5
+ type Request,
6
+ type Response,
7
+ } from "./connection.ts";
8
+ import type { Codec } from "./codec.ts";
9
+ import { NotFoundError, TaskNotFoundError } from "./errors.ts";
10
+ import { BrrrTaskSymbol } from "./symbol.ts";
11
+
12
+ export type Task<A extends unknown[] = any[], R = any> = ((
13
+ ...args: [ActiveWorker, ...A]
14
+ ) => R) & {
15
+ readonly [BrrrTaskSymbol]?: (...args: A) => R;
16
+ };
17
+
18
+ export type StripLeadingActiveWorker<A extends unknown[]> = A extends [
19
+ ActiveWorker,
20
+ ...infer Rest,
21
+ ]
22
+ ? Rest
23
+ : A;
24
+
25
+ export type NoAppTask<A extends unknown[], R> = (
26
+ ...args: StripLeadingActiveWorker<A>
27
+ ) => Promise<R>;
28
+
29
+ export type Handlers = Readonly<Record<string, Task>>;
30
+
31
+ export type TaskIdentifier<A extends unknown[], R> =
32
+ | ((...args: A) => R | Promise<R>)
33
+ | string;
34
+
35
+ export function taskIdentifierToName(
36
+ identifier: TaskIdentifier<any[], any>,
37
+ handlers: Handlers,
38
+ ): string {
39
+ if (typeof identifier === "string") {
40
+ return identifier;
41
+ }
42
+ for (const [name, handler] of Object.entries(handlers)) {
43
+ if (handler[BrrrTaskSymbol] === identifier || handler === identifier) {
44
+ return name;
45
+ }
46
+ }
47
+ throw new TaskNotFoundError(identifier.name);
48
+ }
49
+
50
+ export function taskFn<A extends unknown[], R>(
51
+ fn: (...args: A) => R,
52
+ ): Task<A, R> {
53
+ const task: Task<A, R> = (_: ActiveWorker, ...args: A): R => fn(...args);
54
+ return Object.defineProperty(task, BrrrTaskSymbol, {
55
+ value: fn satisfies Task<A, R>[typeof BrrrTaskSymbol],
56
+ writable: false,
57
+ configurable: false,
58
+ });
59
+ }
60
+
61
+ export class AppConsumer {
62
+ public readonly codec: Codec;
63
+ public readonly connection: Connection;
64
+ public readonly handlers: Handlers;
65
+
66
+ public constructor(
67
+ codec: Codec,
68
+ connection: Connection,
69
+ handlers: Handlers = {},
70
+ ) {
71
+ this.codec = codec;
72
+ this.connection = connection;
73
+ this.handlers = handlers;
74
+ }
75
+
76
+ public schedule<A extends unknown[], R>(
77
+ taskIdentifier: TaskIdentifier<A, R>,
78
+ topic: string,
79
+ ): NoAppTask<A, void> {
80
+ const taskName = taskIdentifierToName(taskIdentifier, this.handlers);
81
+ return async (...args: StripLeadingActiveWorker<A>) => {
82
+ const call = await this.codec.encodeCall(taskName, args);
83
+ await this.connection.scheduleRaw(topic, call);
84
+ };
85
+ }
86
+
87
+ public read<A extends unknown[], R>(
88
+ taskIdentifier: TaskIdentifier<A, R>,
89
+ ): NoAppTask<A, R> {
90
+ return async (...args: StripLeadingActiveWorker<A>) => {
91
+ const taskName = taskIdentifierToName(taskIdentifier, this.handlers);
92
+ const call = await this.codec.encodeCall(taskName, args);
93
+ const payload = await this.connection.memory.getValue(call.callHash);
94
+ if (!payload) {
95
+ throw new NotFoundError({
96
+ type: "value",
97
+ callHash: call.callHash,
98
+ });
99
+ }
100
+ return this.codec.decodeReturn(taskName, payload) as R;
101
+ };
102
+ }
103
+ }
104
+
105
+ export class AppWorker extends AppConsumer {
106
+ public readonly handle = async (
107
+ request: Request,
108
+ connection: Connection,
109
+ ): Promise<Response | Defer> => {
110
+ const handler = this.handlers[request.call.taskName];
111
+ if (!handler) {
112
+ throw new TaskNotFoundError(request.call.taskName);
113
+ }
114
+ try {
115
+ const activeWorker = new ActiveWorker(
116
+ connection,
117
+ this.codec,
118
+ this.handlers,
119
+ );
120
+ const payload = await this.codec.invokeTask(request.call, (...args) => {
121
+ return handler(activeWorker, ...args);
122
+ });
123
+ return { payload };
124
+ } catch (err) {
125
+ if (err instanceof Defer) {
126
+ return err;
127
+ }
128
+ throw err;
129
+ }
130
+ };
131
+ }
132
+
133
+ export class ActiveWorker {
134
+ private readonly connection: Connection;
135
+ private readonly codec: Codec;
136
+ private readonly handlers: Handlers;
137
+
138
+ public constructor(connection: Connection, codec: Codec, handlers: Handlers) {
139
+ this.connection = connection;
140
+ this.codec = codec;
141
+ this.handlers = handlers;
142
+ }
143
+
144
+ public call<A extends unknown[], R>(
145
+ taskIdentifier: TaskIdentifier<A, R>,
146
+ topic?: string | undefined,
147
+ ): NoAppTask<A, R> {
148
+ const taskName = taskIdentifierToName(taskIdentifier, this.handlers);
149
+ return async (...args: StripLeadingActiveWorker<A>): Promise<R> => {
150
+ const call = await this.codec.encodeCall(taskName, args);
151
+ const payload = await this.connection.memory.getValue(call.callHash);
152
+ if (!payload) {
153
+ throw new Defer({ topic, call });
154
+ }
155
+ return this.codec.decodeReturn(taskName, payload) as R;
156
+ };
157
+ }
158
+
159
+ public async gather<T1>(t1: T1): Promise<[Awaited<T1>]>;
160
+ public async gather<T1, T2>(
161
+ t1: T1,
162
+ t2: T2,
163
+ ): Promise<[Awaited<T1>, Awaited<T2>]>;
164
+ public async gather<T1, T2, T3>(
165
+ t1: T1,
166
+ t2: T2,
167
+ t3: T3,
168
+ ): Promise<[Awaited<T1>, Awaited<T2>, Awaited<T3>]>;
169
+ public async gather<T1, T2, T3, T4>(
170
+ t1: T1,
171
+ t2: T2,
172
+ t3: T3,
173
+ t4: T4,
174
+ ): Promise<[Awaited<T1>, Awaited<T2>, Awaited<T3>, Awaited<T4>]>;
175
+ public async gather<T1, T2, T3, T4, T5>(
176
+ t1: T1,
177
+ t2: T2,
178
+ t3: T3,
179
+ t4: T4,
180
+ t5: T5,
181
+ ): Promise<[Awaited<T1>, Awaited<T2>, Awaited<T3>, Awaited<T4>, Awaited<T5>]>;
182
+ public async gather<T>(...promises: Promise<T>[]): Promise<Awaited<T>[]>;
183
+ public async gather<T>(...promises: Promise<T>[]): Promise<Awaited<T>[]> {
184
+ const deferredCalls: DeferredCall[] = [];
185
+ const values: Awaited<T>[] = [];
186
+ for (const promise of promises) {
187
+ try {
188
+ values.push(await promise);
189
+ } catch (err) {
190
+ if (!(err instanceof Defer)) {
191
+ throw err;
192
+ }
193
+ deferredCalls.push(...err.calls);
194
+ }
195
+ }
196
+ if (deferredCalls.length) {
197
+ throw new Defer(...deferredCalls);
198
+ }
199
+ return values;
200
+ }
201
+ }
@@ -0,0 +1,20 @@
1
+ import { suite } from "node:test";
2
+ import { Dynamo } from "./dynamo.ts";
3
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
4
+ import { storeContractTest } from "../store.test.ts";
5
+ import { randomUUID } from "node:crypto";
6
+
7
+ await suite(import.meta.filename, async () => {
8
+ const client = new DynamoDBClient();
9
+
10
+ await storeContractTest(async () => {
11
+ const dynamo = new Dynamo(client, randomUUID());
12
+ await dynamo.createTable();
13
+ return {
14
+ store: dynamo,
15
+ async [Symbol.asyncDispose]() {
16
+ await dynamo.deleteTable();
17
+ },
18
+ };
19
+ });
20
+ });
@@ -0,0 +1,184 @@
1
+ import {
2
+ ConditionalCheckFailedException,
3
+ CreateTableCommand,
4
+ DeleteTableCommand,
5
+ type DynamoDBClient,
6
+ ResourceInUseException,
7
+ } from "@aws-sdk/client-dynamodb";
8
+ import {
9
+ DeleteCommand,
10
+ DynamoDBDocumentClient,
11
+ GetCommand,
12
+ PutCommand,
13
+ UpdateCommand,
14
+ } from "@aws-sdk/lib-dynamodb";
15
+ import type { MemKey, Store } from "../store.ts";
16
+ import type { NativeAttributeValue } from "@aws-sdk/util-dynamodb";
17
+
18
+ export class Dynamo implements Store {
19
+ private readonly client: DynamoDBDocumentClient;
20
+ private readonly tableName: string;
21
+
22
+ public constructor(dynamoDbClient: DynamoDBClient, tableName: string) {
23
+ this.client = DynamoDBDocumentClient.from(dynamoDbClient);
24
+ this.tableName = tableName;
25
+ }
26
+
27
+ public async has(key: MemKey): Promise<boolean> {
28
+ const { Item } = await this.client.send(
29
+ new GetCommand({
30
+ TableName: this.tableName,
31
+ Key: this.key(key),
32
+ ProjectionExpression: "pk",
33
+ }),
34
+ );
35
+ return !!Item;
36
+ }
37
+
38
+ public async get(key: MemKey): Promise<Uint8Array | undefined> {
39
+ const { Item } = await this.client.send(
40
+ new GetCommand({
41
+ TableName: this.tableName,
42
+ Key: this.key(key),
43
+ }),
44
+ );
45
+ return Item?.value;
46
+ }
47
+
48
+ public async set(key: MemKey, value: Uint8Array): Promise<void> {
49
+ await this.client.send(
50
+ new PutCommand({
51
+ TableName: this.tableName,
52
+ Item: {
53
+ ...this.key(key),
54
+ value,
55
+ },
56
+ }),
57
+ );
58
+ }
59
+
60
+ public async delete(key: MemKey): Promise<void> {
61
+ await this.client.send(
62
+ new DeleteCommand({
63
+ TableName: this.tableName,
64
+ Key: this.key(key),
65
+ ReturnValues: "ALL_OLD",
66
+ }),
67
+ );
68
+ }
69
+
70
+ public async setNewValue(key: MemKey, value: Uint8Array): Promise<boolean> {
71
+ try {
72
+ await this.client.send(
73
+ new UpdateCommand({
74
+ TableName: this.tableName,
75
+ Key: this.key(key),
76
+ UpdateExpression: "SET #value = :value",
77
+ ConditionExpression: "attribute_not_exists(#value)",
78
+ ExpressionAttributeNames: { "#value": "value" },
79
+ ExpressionAttributeValues: { ":value": value },
80
+ }),
81
+ );
82
+ return true;
83
+ } catch (err: unknown) {
84
+ if (err instanceof ConditionalCheckFailedException) {
85
+ return false;
86
+ }
87
+ throw err;
88
+ }
89
+ }
90
+
91
+ public async compareAndSet(
92
+ key: MemKey,
93
+ value: Uint8Array,
94
+ expected: Uint8Array,
95
+ ): Promise<boolean> {
96
+ try {
97
+ await this.client.send(
98
+ new UpdateCommand({
99
+ TableName: this.tableName,
100
+ Key: this.key(key),
101
+ UpdateExpression: "SET #value = :value",
102
+ ConditionExpression: "#value = :expected",
103
+ ExpressionAttributeNames: { "#value": "value" },
104
+ ExpressionAttributeValues: {
105
+ ":value": value,
106
+ ":expected": expected,
107
+ },
108
+ }),
109
+ );
110
+ return true;
111
+ } catch (err: unknown) {
112
+ if (err instanceof ConditionalCheckFailedException) {
113
+ return false;
114
+ }
115
+ throw err;
116
+ }
117
+ }
118
+
119
+ public async compareAndDelete(
120
+ key: MemKey,
121
+ expected: Uint8Array,
122
+ ): Promise<boolean> {
123
+ try {
124
+ await this.client.send(
125
+ new DeleteCommand({
126
+ TableName: this.tableName,
127
+ Key: this.key(key),
128
+ ConditionExpression:
129
+ "attribute_exists(#value) AND #value = :expected",
130
+ ExpressionAttributeNames: { "#value": "value" },
131
+ ExpressionAttributeValues: { ":expected": expected },
132
+ }),
133
+ );
134
+ return true;
135
+ } catch (err: unknown) {
136
+ if (err instanceof ConditionalCheckFailedException) {
137
+ return false;
138
+ }
139
+ throw err;
140
+ }
141
+ }
142
+
143
+ public async createTable(): Promise<void> {
144
+ try {
145
+ await this.client.send(
146
+ new CreateTableCommand({
147
+ TableName: this.tableName,
148
+ KeySchema: [
149
+ { AttributeName: "pk", KeyType: "HASH" },
150
+ { AttributeName: "sk", KeyType: "RANGE" },
151
+ ],
152
+ AttributeDefinitions: [
153
+ { AttributeName: "pk", AttributeType: "S" },
154
+ { AttributeName: "sk", AttributeType: "S" },
155
+ ],
156
+ ProvisionedThroughput: {
157
+ ReadCapacityUnits: 5,
158
+ WriteCapacityUnits: 5,
159
+ },
160
+ }),
161
+ );
162
+ } catch (err: unknown) {
163
+ if (err instanceof ResourceInUseException) {
164
+ return;
165
+ }
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ public async deleteTable(): Promise<void> {
171
+ await this.client.send(
172
+ new DeleteTableCommand({
173
+ TableName: this.tableName,
174
+ }),
175
+ );
176
+ }
177
+
178
+ private key(key: MemKey): Record<string, NativeAttributeValue> {
179
+ return {
180
+ pk: key.callHash,
181
+ sk: key.type,
182
+ };
183
+ }
184
+ }
@@ -0,0 +1,19 @@
1
+ import { suite, test } from "node:test";
2
+ import { InMemoryCache, InMemoryStore } from "./in-memory.ts";
3
+ import { cacheContractTest, storeContractTest } from "../store.test.ts";
4
+
5
+ await suite(import.meta.filename, async () => {
6
+ await test(InMemoryStore.name, async () => {
7
+ await storeContractTest(async () => ({
8
+ store: new InMemoryStore(),
9
+ async [Symbol.asyncDispose]() {},
10
+ }));
11
+ });
12
+
13
+ await test(InMemoryCache.name, async () => {
14
+ await cacheContractTest(async () => ({
15
+ cache: new InMemoryCache(),
16
+ async [Symbol.asyncDispose]() {},
17
+ }));
18
+ });
19
+ });
@@ -0,0 +1,110 @@
1
+ import type { Cache, MemKey, Store } from "../store.ts";
2
+ import type { Publisher, Subscriber } from "../emitter.ts";
3
+ import { EventEmitter } from "node:events";
4
+ import type { Call } from "../call.ts";
5
+ import { BrrrTaskDoneEventSymbol } from "../symbol.ts";
6
+
7
+ export class InMemoryStore implements Store {
8
+ private store = new Map<string, Uint8Array>();
9
+
10
+ public async compareAndDelete(
11
+ key: MemKey,
12
+ expected: Uint8Array,
13
+ ): Promise<boolean> {
14
+ const keyStr = this.keyToString(key);
15
+ const value = this.store.get(keyStr);
16
+ if (!value || !this.isEqualBytes(value, expected)) {
17
+ return false;
18
+ }
19
+ this.store.delete(keyStr);
20
+ return true;
21
+ }
22
+
23
+ public async compareAndSet(
24
+ key: MemKey,
25
+ value: Uint8Array,
26
+ expected: Uint8Array,
27
+ ): Promise<boolean> {
28
+ const keyStr = this.keyToString(key);
29
+ const current = this.store.get(keyStr);
30
+ if (!current || !this.isEqualBytes(current, expected)) {
31
+ return false;
32
+ }
33
+ this.store.set(keyStr, value);
34
+ return true;
35
+ }
36
+
37
+ public async delete(key: MemKey): Promise<void> {
38
+ const keyStr = this.keyToString(key);
39
+ this.store.delete(keyStr);
40
+ }
41
+
42
+ public async get(key: MemKey): Promise<Uint8Array | undefined> {
43
+ const keyStr = this.keyToString(key);
44
+ return this.store.get(keyStr);
45
+ }
46
+
47
+ public async has(key: MemKey): Promise<boolean> {
48
+ const keyStr = this.keyToString(key);
49
+ return this.store.has(keyStr);
50
+ }
51
+
52
+ public async set(key: MemKey, value: Uint8Array): Promise<void> {
53
+ const keyStr = this.keyToString(key);
54
+ this.store.set(keyStr, value);
55
+ }
56
+
57
+ public async setNewValue(key: MemKey, value: Uint8Array): Promise<boolean> {
58
+ const keyStr = this.keyToString(key);
59
+ if (this.store.has(keyStr)) {
60
+ return false;
61
+ }
62
+ this.store.set(keyStr, value);
63
+ return true;
64
+ }
65
+
66
+ private keyToString(key: MemKey): string {
67
+ return `${key.type}/${key.callHash}`;
68
+ }
69
+
70
+ private isEqualBytes(a: Uint8Array, b: Uint8Array): boolean {
71
+ return a.length === b.length && a.every((it, i) => it === b[i]);
72
+ }
73
+ }
74
+
75
+ export class InMemoryCache implements Cache {
76
+ private readonly cache = new Map<string, number>();
77
+
78
+ public async incr(key: string): Promise<number> {
79
+ const next = (this.cache.get(key) ?? 0) + 1;
80
+ this.cache.set(key, next);
81
+ return next;
82
+ }
83
+ }
84
+
85
+ export class InMemoryEmitter implements Publisher, Subscriber {
86
+ private readonly emitter = new EventEmitter();
87
+ private readonly eventEmitter = new EventEmitter();
88
+
89
+ public on(topic: string, listener: (callId: string) => void): void {
90
+ this.emitter.on(topic, listener);
91
+ }
92
+
93
+ public onEventSymbol(
94
+ event: typeof BrrrTaskDoneEventSymbol,
95
+ listener: (call: Call) => void,
96
+ ): void {
97
+ this.eventEmitter.on(event, listener);
98
+ }
99
+
100
+ public async emit(topic: string, callId: string): Promise<void> {
101
+ this.emitter.emit(topic, callId);
102
+ }
103
+
104
+ public async emitEventSymbol(
105
+ event: typeof BrrrTaskDoneEventSymbol,
106
+ call: Call,
107
+ ): Promise<void> {
108
+ this.eventEmitter.emit(event, call);
109
+ }
110
+ }
@@ -0,0 +1,61 @@
1
+ import { suite, test } from "node:test";
2
+ import { ok, strictEqual } from "node:assert/strict";
3
+ import { env } from "node:process";
4
+ import { createClientPool } from "redis";
5
+ import { Redis } from "./redis.ts";
6
+ import { cacheContractTest } from "../store.test.ts";
7
+
8
+ await suite(import.meta.filename, async () => {
9
+ async function acquireResource() {
10
+ ok(env.BRRR_TEST_REDIS_URL);
11
+ const client = createClientPool(
12
+ {
13
+ RESP: 3,
14
+ url: env.BRRR_TEST_REDIS_URL,
15
+ },
16
+ {
17
+ cleanupDelay: 0,
18
+ },
19
+ );
20
+ const redis = new Redis(client);
21
+ await redis.connect();
22
+ return {
23
+ cache: redis,
24
+ async [Symbol.asyncDispose]() {
25
+ await redis.client.flushAll();
26
+ redis.destroy();
27
+ },
28
+ };
29
+ }
30
+
31
+ await cacheContractTest(acquireResource);
32
+
33
+ await suite("as message queue", async () => {
34
+ const topic = "test-topic";
35
+ const message = "some-message";
36
+
37
+ await test("push & pop message", async () => {
38
+ await using resource = await acquireResource();
39
+ await resource.cache.push(topic, message);
40
+ strictEqual(await resource.cache.pop(topic), message);
41
+ });
42
+
43
+ await test("FIFO", async () => {
44
+ await using resource = await acquireResource();
45
+ const messages = ["first", "second", "third"];
46
+ for (const message of messages) {
47
+ await resource.cache.push(topic, message);
48
+ }
49
+ for (const message of messages) {
50
+ strictEqual(await resource.cache.pop(topic), message);
51
+ }
52
+ });
53
+
54
+ await test("pop before push", async () => {
55
+ await using resource = await acquireResource();
56
+ const popped = resource.cache.pop(topic);
57
+ await resource.cache.push(topic, message);
58
+ strictEqual(await popped, message);
59
+ });
60
+ });
61
+ });
@@ -0,0 +1,48 @@
1
+ import {
2
+ type RedisClientPoolType,
3
+ type RedisFunctions,
4
+ type RedisModules,
5
+ type RedisScripts,
6
+ } from "redis";
7
+ import type { Cache } from "../store.ts";
8
+
9
+ export class Redis implements Cache {
10
+ public readonly client: RedisClientPoolType<
11
+ RedisModules,
12
+ RedisFunctions,
13
+ RedisScripts,
14
+ 3
15
+ >;
16
+
17
+ public constructor(client: typeof this.client) {
18
+ this.client = client;
19
+ }
20
+
21
+ public async connect(): Promise<void> {
22
+ await this.client.connect();
23
+ }
24
+
25
+ public async push(topic: string, content: string): Promise<void> {
26
+ await this.client.rPush(topic, content);
27
+ }
28
+
29
+ public async pop(
30
+ topic: string,
31
+ timeoutMs: number = 20_000,
32
+ ): Promise<string | undefined> {
33
+ const response = await this.client.blPop(topic, timeoutMs / 1000);
34
+ return response?.element;
35
+ }
36
+
37
+ public async incr(key: string): Promise<number> {
38
+ return this.client.incr(key);
39
+ }
40
+
41
+ public destroy(): void {
42
+ this.client.destroy();
43
+ }
44
+
45
+ public async close(): Promise<void> {
46
+ await this.client.close();
47
+ }
48
+ }
package/src/call.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A full brrr request payload.
3
+ *
4
+ * This is a low-level brrr primitive.
5
+ *
6
+ * The memo key must be generated by the instantiator of this class, and it
7
+ * must be deterministic: the "same" args and kwargs must always encode to the
8
+ * same memo key.
9
+ *
10
+ * Using the same memo key, we store the task and its argv here so we can
11
+ * retrieve them in workers.
12
+ *
13
+ * <docsync>Call</docsync>
14
+ */
15
+ export interface Call {
16
+ readonly taskName: string;
17
+ readonly payload: Uint8Array;
18
+ readonly callHash: string;
19
+ }