encore.dev 0.0.0-devel.202311141645
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/LICENSE +362 -0
- package/README.md +4 -0
- package/api/mod.ts +47 -0
- package/config/mod.ts +2 -0
- package/config/secrets.ts +27 -0
- package/cron/mod.ts +17 -0
- package/dist/api/mod.d.ts +7 -0
- package/dist/api/mod.js +5 -0
- package/dist/api/mod.js.map +1 -0
- package/dist/config/mod.d.ts +2 -0
- package/dist/config/mod.js +2 -0
- package/dist/config/mod.js.map +1 -0
- package/dist/config/secrets.d.ts +11 -0
- package/dist/config/secrets.js +15 -0
- package/dist/config/secrets.js.map +1 -0
- package/dist/cron/mod.d.ts +14 -0
- package/dist/cron/mod.js +10 -0
- package/dist/cron/mod.js.map +1 -0
- package/dist/internal/types/mod.d.ts +7 -0
- package/dist/internal/types/mod.js +2 -0
- package/dist/internal/types/mod.js.map +1 -0
- package/dist/log/mod.d.ts +3 -0
- package/dist/log/mod.js +4 -0
- package/dist/log/mod.js.map +1 -0
- package/dist/mod.d.ts +0 -0
- package/dist/mod.js +2 -0
- package/dist/mod.js.map +1 -0
- package/dist/pubsub/acker.d.ts +26 -0
- package/dist/pubsub/acker.js +73 -0
- package/dist/pubsub/acker.js.map +1 -0
- package/dist/pubsub/attributes.d.ts +59 -0
- package/dist/pubsub/attributes.js +2 -0
- package/dist/pubsub/attributes.js.map +1 -0
- package/dist/pubsub/mod.d.ts +5 -0
- package/dist/pubsub/mod.js +3 -0
- package/dist/pubsub/mod.js.map +1 -0
- package/dist/pubsub/subscription.d.ts +100 -0
- package/dist/pubsub/subscription.js +164 -0
- package/dist/pubsub/subscription.js.map +1 -0
- package/dist/pubsub/topic.d.ts +105 -0
- package/dist/pubsub/topic.js +62 -0
- package/dist/pubsub/topic.js.map +1 -0
- package/dist/storage/sqldb/database.d.ts +51 -0
- package/dist/storage/sqldb/database.js +78 -0
- package/dist/storage/sqldb/database.js.map +1 -0
- package/dist/storage/sqldb/mod.d.ts +2 -0
- package/dist/storage/sqldb/mod.js +2 -0
- package/dist/storage/sqldb/mod.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/internal/types/mod.ts +10 -0
- package/log/mod.ts +4 -0
- package/mod.ts +0 -0
- package/package.json +77 -0
- package/pubsub/acker.ts +79 -0
- package/pubsub/attributes.ts +66 -0
- package/pubsub/mod.ts +7 -0
- package/pubsub/subscription.ts +302 -0
- package/pubsub/topic.ts +132 -0
- package/storage/sqldb/database.ts +105 -0
- package/storage/sqldb/mod.ts +2 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { api } from "@encore.dev/internal-runtime";
|
|
2
|
+
import reqtrack, { Context } from "@encore.dev/internal-runtime/reqtrack/index";
|
|
3
|
+
import {
|
|
4
|
+
newSpanID,
|
|
5
|
+
newTraceID,
|
|
6
|
+
} from "@encore.dev/internal-runtime/reqtrack/tracecontext";
|
|
7
|
+
import {
|
|
8
|
+
logCtx,
|
|
9
|
+
withLogCtx,
|
|
10
|
+
} from "@encore.dev/internal-runtime/reqtrack/logging";
|
|
11
|
+
import {
|
|
12
|
+
durationToProto,
|
|
13
|
+
now,
|
|
14
|
+
parseDuration,
|
|
15
|
+
since,
|
|
16
|
+
toDurationStr,
|
|
17
|
+
} from "@encore.dev/internal-runtime/utils/timers";
|
|
18
|
+
import { runtime } from "@encore.dev/internal-runtime/jsruntime";
|
|
19
|
+
import log, { type Logger } from "../log/mod";
|
|
20
|
+
import { PubSub } from "@encore.dev/sidecar-api";
|
|
21
|
+
import { DurationString } from "../internal/types/mod";
|
|
22
|
+
import { Acker } from "./acker";
|
|
23
|
+
import { Topic } from "./topic";
|
|
24
|
+
|
|
25
|
+
export class Subscription<Msg extends object> {
|
|
26
|
+
private readonly client_id: string;
|
|
27
|
+
private readonly topic: Topic<Msg>;
|
|
28
|
+
private readonly name: string;
|
|
29
|
+
private readonly handler: (msg: Msg, acker: Acker) => Promise<unknown>;
|
|
30
|
+
private readonly config: PubSub.SubscriptionConfig;
|
|
31
|
+
private readonly logger: Logger;
|
|
32
|
+
private failedState = false;
|
|
33
|
+
private activeDeliveries: string[] = [];
|
|
34
|
+
|
|
35
|
+
constructor(topic: Topic<Msg>, name: string, cfg: SubscriptionConfig<Msg>) {
|
|
36
|
+
// Generate a unique client ID for this instance
|
|
37
|
+
const id = new Uint8Array(16);
|
|
38
|
+
runtime().getRandomValues(id);
|
|
39
|
+
this.client_id = Buffer.from(id).toString("hex");
|
|
40
|
+
|
|
41
|
+
this.topic = topic;
|
|
42
|
+
this.name = name;
|
|
43
|
+
this.handler = cfg.handler;
|
|
44
|
+
this.logger = log.with({ topic: topic.name, subscription: name });
|
|
45
|
+
|
|
46
|
+
const ackDeadline = parseDuration(cfg.ackDeadline ?? "30s");
|
|
47
|
+
const minBackoff = parseDuration(cfg.retryPolicy?.minBackoff ?? "10s");
|
|
48
|
+
const maxBackoff = parseDuration(cfg.retryPolicy?.maxBackoff ?? "10m");
|
|
49
|
+
|
|
50
|
+
this.config = new PubSub.SubscriptionConfig({
|
|
51
|
+
maxConcurrency: cfg.maxConcurrency ?? -1,
|
|
52
|
+
ackDeadline: durationToProto(ackDeadline),
|
|
53
|
+
retryPolicy: new PubSub.RetryPolicy({
|
|
54
|
+
minBackoff: durationToProto(minBackoff),
|
|
55
|
+
maxBackoff: durationToProto(maxBackoff),
|
|
56
|
+
maxRetries: cfg.retryPolicy?.maxRetries ?? 100,
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
let backoff = 250; // Start at 250ms for exponential backoff
|
|
61
|
+
const start = () => {
|
|
62
|
+
this.logger.trace("starting subscription");
|
|
63
|
+
this.#startSubscription()
|
|
64
|
+
.then(() => this.logger.warn("subscription ended"))
|
|
65
|
+
.catch((e: unknown) => {
|
|
66
|
+
this.failedState = true;
|
|
67
|
+
backoff *= 2;
|
|
68
|
+
if (backoff > 60 * 1000) {
|
|
69
|
+
// cap the backoff at 1 minute
|
|
70
|
+
backoff = 60 * 1000;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.logger.error(e, "subscription failed with error");
|
|
74
|
+
setTimeout(start, backoff);
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
this.logger.info("registered subscription");
|
|
79
|
+
|
|
80
|
+
// Start the subscription
|
|
81
|
+
process.nextTick(start);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async #startSubscription() {
|
|
85
|
+
const resp = api().pubsub.subscribe(
|
|
86
|
+
new PubSub.SubscribeRequest({
|
|
87
|
+
clientId: this.client_id,
|
|
88
|
+
topic: this.topic.name,
|
|
89
|
+
subscription: this.name,
|
|
90
|
+
config: this.config,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Now we have an active subscription, we touch messages if we have active messages
|
|
95
|
+
const interval = setInterval(() => {
|
|
96
|
+
this.#touchActiveMessages();
|
|
97
|
+
}, 30 * 1000);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
for await (const msg of resp) {
|
|
101
|
+
if (this.failedState) {
|
|
102
|
+
this.logger.info("subscription recovered");
|
|
103
|
+
this.failedState = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
process.nextTick(() => this.#handleMessage(msg));
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
clearInterval(interval);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#touchActiveMessages() {
|
|
114
|
+
if (this.activeDeliveries.length === 0) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const acks = this.activeDeliveries;
|
|
118
|
+
|
|
119
|
+
api()
|
|
120
|
+
.pubsub.touch({
|
|
121
|
+
clientId: this.client_id,
|
|
122
|
+
ackIds: acks,
|
|
123
|
+
})
|
|
124
|
+
.then(() => {
|
|
125
|
+
this.logger.trace("touched active messages", { ack_ids: acks });
|
|
126
|
+
})
|
|
127
|
+
.catch((e: unknown) => {
|
|
128
|
+
this.logger.error(e, "failed to touch messages", { ack_ids: acks });
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async #handleMessage(event: PubSub.SubscribeResponse) {
|
|
133
|
+
if (event.event.case !== "messageDelivery") {
|
|
134
|
+
// If it's a keep alive or some other unknown message
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const msg = event.event.value;
|
|
138
|
+
|
|
139
|
+
const logCtx: logCtx = {
|
|
140
|
+
topic: this.topic.name,
|
|
141
|
+
subscription: this.name,
|
|
142
|
+
msg_id: msg.msgId,
|
|
143
|
+
delivery_attempt: msg.deliveryAttempt,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const traceID = newTraceID();
|
|
147
|
+
const spanID = newSpanID();
|
|
148
|
+
const ctx: Context = {
|
|
149
|
+
traceID: traceID,
|
|
150
|
+
spanID: spanID,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Record this ack ID as being active
|
|
154
|
+
this.activeDeliveries.push(msg.ackId);
|
|
155
|
+
|
|
156
|
+
return reqtrack.run(ctx, async () => {
|
|
157
|
+
return withLogCtx(logCtx, async () => {
|
|
158
|
+
const acker = new Acker(
|
|
159
|
+
() => this.#acknowledge(msg.ackId),
|
|
160
|
+
() => this.#negativeAcknowledge(msg.ackId),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
this.logger.info("starting request");
|
|
165
|
+
const data = Buffer.from(
|
|
166
|
+
msg.message?.data ?? Uint8Array.of(),
|
|
167
|
+
).toString();
|
|
168
|
+
|
|
169
|
+
const start = now();
|
|
170
|
+
await this.handler(JSON.parse(data), acker);
|
|
171
|
+
this.logger.info("request completed", {
|
|
172
|
+
duration: toDurationStr(since(start)),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const hasAcked = await acker.currentResult();
|
|
176
|
+
if (!hasAcked) {
|
|
177
|
+
await acker.ack();
|
|
178
|
+
}
|
|
179
|
+
} catch (e: unknown) {
|
|
180
|
+
this.logger.error(e, "request failed");
|
|
181
|
+
|
|
182
|
+
const hasAcked = await acker.currentResult();
|
|
183
|
+
if (!hasAcked) {
|
|
184
|
+
await acker.nack();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async #acknowledge(ackID: string): Promise<void> {
|
|
192
|
+
await api().pubsub.acknowledge({
|
|
193
|
+
clientId: this.client_id,
|
|
194
|
+
ackId: ackID,
|
|
195
|
+
});
|
|
196
|
+
this.activeDeliveries = this.activeDeliveries.filter((id) => id !== ackID);
|
|
197
|
+
this.logger.trace("acknowledged message", { ack_id: ackID });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async #negativeAcknowledge(ackID: string): Promise<void> {
|
|
201
|
+
await api().pubsub.negativeAcknowledge({
|
|
202
|
+
clientId: this.client_id,
|
|
203
|
+
ackId: ackID,
|
|
204
|
+
});
|
|
205
|
+
this.activeDeliveries = this.activeDeliveries.filter((id) => id !== ackID);
|
|
206
|
+
this.logger.trace("negatively acknowledged message", { ack_id: ackID });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* SubscriptionConfig is used when creating a subscription
|
|
212
|
+
*
|
|
213
|
+
* The values given here may be clamped to the supported values by
|
|
214
|
+
* the target cloud. (i.e. ack deadline may be brought within the supported range
|
|
215
|
+
* by the target cloud pubsub implementation).
|
|
216
|
+
*/
|
|
217
|
+
export interface SubscriptionConfig<Msg> {
|
|
218
|
+
/**
|
|
219
|
+
* Handler is the function which will be called to process a message
|
|
220
|
+
* sent on the topic.
|
|
221
|
+
*
|
|
222
|
+
* When this function returns an error the message will be
|
|
223
|
+
* negatively acknowledged (nacked), which will cause a redelivery
|
|
224
|
+
* attempt to be made (unless the retry policy's MaxRetries has been reached).
|
|
225
|
+
*/
|
|
226
|
+
handler: (msg: Msg) => Promise<unknown>;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* MaxConcurrency is the maximum number of messages which will be processed
|
|
230
|
+
* simultaneously per instance of the service for this subscription.
|
|
231
|
+
*
|
|
232
|
+
* Note that this is per instance of the service, so if your service has
|
|
233
|
+
* scaled to 10 instances and this is set to 10, then 100 messages could be
|
|
234
|
+
* processed simultaneously.
|
|
235
|
+
*
|
|
236
|
+
* If the value is negative, then there will be no limit on the number
|
|
237
|
+
* of messages processed simultaneously.
|
|
238
|
+
*
|
|
239
|
+
* Note: This is not supported by all cloud providers; specifically on GCP
|
|
240
|
+
* when using Cloud Run instances on an unordered topic the subscription will
|
|
241
|
+
* be configured as a Push Subscription and will have an adaptive concurrency
|
|
242
|
+
* See [GCP Push Delivery Rate](https://cloud.google.com/pubsub/docs/push#push_delivery_rate).
|
|
243
|
+
*
|
|
244
|
+
* This setting also has no effect on Encore Cloud environments.
|
|
245
|
+
* If not set, it uses a reasonable default based on the cloud provider.
|
|
246
|
+
*/
|
|
247
|
+
maxConcurrency?: number;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* AckDeadline is the time a consumer has to process a message
|
|
251
|
+
* before it's returned to the subscription
|
|
252
|
+
*
|
|
253
|
+
* Default is 30 seconds, however the ack deadline must be at least
|
|
254
|
+
* 1 second.
|
|
255
|
+
*/
|
|
256
|
+
ackDeadline?: DurationString;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* MessageRetention is how long an undelivered message is kept
|
|
260
|
+
* on the topic before it's purged.
|
|
261
|
+
*
|
|
262
|
+
* Default is 7 days.
|
|
263
|
+
*/
|
|
264
|
+
messageRetention?: DurationString;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* RetryPolicy defines how a message should be retried when
|
|
268
|
+
* the subscriber returns an error
|
|
269
|
+
*/
|
|
270
|
+
retryPolicy?: RetryPolicy;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* RetryPolicy defines how a subscription should handle retries
|
|
275
|
+
* after errors either delivering the message or processing the message.
|
|
276
|
+
*
|
|
277
|
+
* The values given to this structure are parsed at compile time, such that
|
|
278
|
+
* the correct Cloud resources can be provisioned to support the queue.
|
|
279
|
+
*
|
|
280
|
+
* As such the values given here may be clamped to the supported values by
|
|
281
|
+
* the target cloud. (i.e. min/max values brought within the supported range
|
|
282
|
+
* by the target cloud).
|
|
283
|
+
*/
|
|
284
|
+
export interface RetryPolicy {
|
|
285
|
+
/**
|
|
286
|
+
* The minimum time to wait between retries. Defaults to 10 seconds.
|
|
287
|
+
*/
|
|
288
|
+
minBackoff?: DurationString;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The maximum time to wait between retries. Defaults to 10 minutes.
|
|
292
|
+
*/
|
|
293
|
+
maxBackoff?: DurationString;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* MaxRetries is used to control deadletter queuing logic, when:
|
|
297
|
+
* n == 0: A default value of 100 retries will be used
|
|
298
|
+
* n > 0: Encore will forward a message to a dead letter queue after n retries
|
|
299
|
+
* n == pubsub.InfiniteRetries: Messages will not be forwarded to the dead letter queue by the Encore framework
|
|
300
|
+
*/
|
|
301
|
+
maxRetries?: number;
|
|
302
|
+
}
|
package/pubsub/topic.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { api } from "@encore.dev/internal-runtime";
|
|
2
|
+
import reqtrack from "@encore.dev/internal-runtime/reqtrack/index";
|
|
3
|
+
import { PubSub } from "@encore.dev/sidecar-api";
|
|
4
|
+
import { AttributesOf } from "./attributes";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A topic is a resource to which you can publish messages
|
|
8
|
+
* to be delivered to subscribers of that topic.
|
|
9
|
+
*/
|
|
10
|
+
export class Topic<Msg extends object> {
|
|
11
|
+
public readonly name: string;
|
|
12
|
+
public readonly cfg: TopicConfig<Msg>;
|
|
13
|
+
|
|
14
|
+
constructor(name: string, cfg: TopicConfig<Msg>) {
|
|
15
|
+
this.name = name;
|
|
16
|
+
this.cfg = cfg;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public async publish(msg: Msg): Promise<string> {
|
|
20
|
+
const res = await api().pubsub.publish(
|
|
21
|
+
new PubSub.PublishRequest({
|
|
22
|
+
topic: this.name,
|
|
23
|
+
message: new PubSub.Message({
|
|
24
|
+
data: Buffer.from(JSON.stringify(msg)),
|
|
25
|
+
attributes: {},
|
|
26
|
+
traceParent: reqtrack.currentTraceParent(),
|
|
27
|
+
}),
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return res.id;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* DeliveryGuarantee is used to configure the delivery contract for a topic.
|
|
37
|
+
*/
|
|
38
|
+
export type DeliveryGuarantee = "at-least-once" | "exactly-once";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* At Least Once delivery guarantees that a message for a subscription is delivered to
|
|
42
|
+
* a consumer at least once.
|
|
43
|
+
*
|
|
44
|
+
* On AWS and GCP there is no limit to the throughput for a topic.
|
|
45
|
+
*/
|
|
46
|
+
export const atLeastOnce: DeliveryGuarantee = "at-least-once";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ExactlyOnce guarantees that a message for a subscription is delivered to
|
|
50
|
+
* a consumer exactly once, to the best of the system's ability.
|
|
51
|
+
*
|
|
52
|
+
* However, there are edge cases when a message might be redelivered.
|
|
53
|
+
* For example, if a networking issue causes the acknowledgement of success
|
|
54
|
+
* processing the message to be lost before the cloud provider receives it.
|
|
55
|
+
*
|
|
56
|
+
* It is also important to note that the ExactlyOnce delivery guarantee only
|
|
57
|
+
* applies to the delivery of the message to the consumer, and not to the
|
|
58
|
+
* original publishing of the message, such that if a message is published twice,
|
|
59
|
+
* such as due to an retry within the application logic, it will be delivered twice.
|
|
60
|
+
* (i.e. ExactlyOnce delivery does not imply message deduplication on publish)
|
|
61
|
+
*
|
|
62
|
+
* As such it's recommended that the subscription handler function is idempotent
|
|
63
|
+
* and is able to handle duplicate messages.
|
|
64
|
+
*
|
|
65
|
+
* Subscriptions attached to ExactlyOnce topics have higher message delivery latency compared to AtLeastOnce.
|
|
66
|
+
*
|
|
67
|
+
* By using ExactlyOnce semantics on a topic, the throughput will be limited depending on the cloud provider:
|
|
68
|
+
* - AWS: 300 messages per second for the topic (see [AWS SQS Quotas]).
|
|
69
|
+
* - GCP: At least 3,000 messages per second across all topics in the region
|
|
70
|
+
* (can be higher on the region see [GCP PubSub Quotas]).
|
|
71
|
+
*
|
|
72
|
+
* [AWS SQS Quotas]: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html
|
|
73
|
+
* [GCP PubSub Quotas]: https://cloud.google.com/pubsub/quotas#quotas
|
|
74
|
+
*/
|
|
75
|
+
export const exactlyOnce: DeliveryGuarantee = "exactly-once";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* TopicConfig is used when creating a Topic
|
|
79
|
+
*/
|
|
80
|
+
export interface TopicConfig<Msg extends object> {
|
|
81
|
+
/**
|
|
82
|
+
* DeliveryGuarantee is used to configure the delivery guarantee of a Topic
|
|
83
|
+
*/
|
|
84
|
+
deliveryGuarantee: DeliveryGuarantee;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* OrderingAttribute is the message attribute to use as a ordering key for
|
|
88
|
+
* messages and delivery will ensure that messages with the same value will
|
|
89
|
+
* be delivered in the order they where published.
|
|
90
|
+
*
|
|
91
|
+
* If OrderingAttribute is not set, messages can be delivered in any order.
|
|
92
|
+
*
|
|
93
|
+
* It is important to note, that in the case of an error being returned by a
|
|
94
|
+
* subscription handler, the message will be retried before any subsequent
|
|
95
|
+
* messages for that ordering key are delivered. This means depending on the
|
|
96
|
+
* retry configuration, a large backlog of messages for a given ordering key
|
|
97
|
+
* may build up. When using OrderingAttribute, it is recommended to use reason
|
|
98
|
+
* about your failure modes and set the retry configuration appropriately.
|
|
99
|
+
*
|
|
100
|
+
* Once the maximum number of retries has been reached, the message will be
|
|
101
|
+
* forwarded to the dead letter queue, and the next message for that ordering
|
|
102
|
+
* key will be delivered.
|
|
103
|
+
*
|
|
104
|
+
* To create attributes on a message, use the `Attribute` type:
|
|
105
|
+
*
|
|
106
|
+
* type UserEvent = {
|
|
107
|
+
* user_id Attribute<string>;
|
|
108
|
+
* action string;
|
|
109
|
+
* }
|
|
110
|
+
*
|
|
111
|
+
* const topic = new Topic<UserEvent>("user-events", {
|
|
112
|
+
* deliveryGuarantee: DeliveryGuarantee.AtLeastOnce,
|
|
113
|
+
* orderingAttribute: "user_id", // Messages with the same user-id will be delivered in the order they where
|
|
114
|
+
* published
|
|
115
|
+
* })
|
|
116
|
+
*
|
|
117
|
+
* topic.publish(ctx, {user_id: "1", action: "login"}) // This message will be delivered before the logout
|
|
118
|
+
* topic.publish(ctx, {user_id: "2", action: "login"}) // This could be delivered at any time because it has a different user id
|
|
119
|
+
* topic.publish(ctx, {user_id: "1", action: "logout"}) // This message will be delivered after the first message
|
|
120
|
+
*
|
|
121
|
+
* By using OrderingAttribute, the throughput will be limited depending on the cloud provider:
|
|
122
|
+
*
|
|
123
|
+
* - AWS: 300 messages per second for the topic (see [AWS SQS Quotas]).
|
|
124
|
+
* - GCP: 1MB/s for each ordering key (see [GCP PubSub Quotas]).
|
|
125
|
+
*
|
|
126
|
+
* Note: OrderingAttribute currently has no effect during local development.
|
|
127
|
+
*
|
|
128
|
+
* [AWS SQS Quotas]: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html
|
|
129
|
+
* [GCP PubSub Quotas]: https://cloud.google.com/pubsub/quotas#resource_limits
|
|
130
|
+
*/
|
|
131
|
+
orderingAttribute?: AttributesOf<Msg>;
|
|
132
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { config } from "@encore.dev/internal-runtime";
|
|
2
|
+
import type { StringLiteral } from "@encore.dev/internal-runtime/utils/constraints";
|
|
3
|
+
import { queryWithComment } from "@encore.dev/internal-runtime/reqtrack/sql";
|
|
4
|
+
import type { Meta } from "@encore.dev/sidecar-api";
|
|
5
|
+
import pg from "pg";
|
|
6
|
+
|
|
7
|
+
export interface SQLDatabaseConfig {
|
|
8
|
+
migrations?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const driverName = "node-pg";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Represents a single row from a query result
|
|
15
|
+
*/
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
export type Row = Record<string, any>;
|
|
18
|
+
|
|
19
|
+
/** Represents a type that can be used in query template literals */
|
|
20
|
+
export type Primitive = string | number | boolean | null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Constructing a new database object will result in Encore provisioning a database with
|
|
24
|
+
* that name and returning this object to represent it.
|
|
25
|
+
*
|
|
26
|
+
* If you want to reference an existing database, use `Database.Named(name)` as it is a
|
|
27
|
+
* compile error to create duplicate databases.
|
|
28
|
+
*/
|
|
29
|
+
export class SQLDatabase {
|
|
30
|
+
private readonly runtimeCfg: Meta.SimpleConfig_Infrastructure_SQLDatabaseConfig;
|
|
31
|
+
private readonly pool: pg.Pool;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a new database with the given name and configuration
|
|
35
|
+
*/
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
37
|
+
constructor(name: string, cfg?: SQLDatabaseConfig) {
|
|
38
|
+
// Grab the runtime configuration for this database
|
|
39
|
+
const runtimeCfg = config().infrastructure?.sqlDatabases[name];
|
|
40
|
+
if (!runtimeCfg) {
|
|
41
|
+
throw new Error(`No database configuration found for ${name}`);
|
|
42
|
+
}
|
|
43
|
+
this.runtimeCfg = runtimeCfg;
|
|
44
|
+
|
|
45
|
+
this.pool = new pg.Pool({
|
|
46
|
+
connectionString: runtimeCfg.connectionString,
|
|
47
|
+
max: 20,
|
|
48
|
+
idleTimeoutMillis: 30000,
|
|
49
|
+
connectionTimeoutMillis: 2000,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reference an existing database by name, if the database doesn't
|
|
55
|
+
* exist yet, use `new Database(name)` instead.
|
|
56
|
+
*/
|
|
57
|
+
static named<name extends string>(name: StringLiteral<name>): SQLDatabase {
|
|
58
|
+
return new SQLDatabase(name);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns the connection string for the database
|
|
63
|
+
*/
|
|
64
|
+
get connectionString(): string {
|
|
65
|
+
return this.runtimeCfg.connectionString;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* q allows you to query the database using a template string, replacing your placeholders in the template
|
|
70
|
+
* with parametrised values. It returns an async generator that you can iterate over to get the results, in a
|
|
71
|
+
* streaming fashion.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
*
|
|
75
|
+
* const email = "foo@example.com";
|
|
76
|
+
* const result = database.q`SELECT id FROM users WHERE email=${email}` // produces a prepared query of
|
|
77
|
+
* // "SELECT id FROM users WHERE email=$1"
|
|
78
|
+
* // with an arg array of [email]
|
|
79
|
+
*
|
|
80
|
+
* for await (const row of result) {
|
|
81
|
+
* console.log(row.id);
|
|
82
|
+
* }
|
|
83
|
+
*/
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
async *q<T extends Row = Record<string, any>>(
|
|
86
|
+
strings: TemplateStringsArray,
|
|
87
|
+
...expr: Primitive[]
|
|
88
|
+
): AsyncGenerator<T> {
|
|
89
|
+
let query = "";
|
|
90
|
+
for (let i = 0; i < strings.length; i++) {
|
|
91
|
+
query += strings[i];
|
|
92
|
+
|
|
93
|
+
if (i < expr.length) {
|
|
94
|
+
query += "$" + (i + 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
query = queryWithComment(query, driverName);
|
|
99
|
+
|
|
100
|
+
const result = await this.pool.query<T>(query, expr);
|
|
101
|
+
for (const row of result.rows) {
|
|
102
|
+
yield row;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|