1import type { RelayManagedEndpoint, RelayManagedEndpointOrigin } from "@t3tools/contracts/relay";
2import { and, eq, exists, inArray, isNull, sql } from "drizzle-orm";
3import { QueryBuilder } from "drizzle-orm/pg-core";
4import * as Context from "effect/Context";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
10import * as SqlError from "effect/sql/SqlError";
11
12import * as RelayDb from "../db.ts";
13import { isManagedEndpointHostname, managedEndpointForHostname } from "../deploymentConfig.ts";
14import { relayEnvironmentLinks, relayManagedEndpointAllocations } from "../persistence/schema.ts";
15
16export interface ManagedEndpointAllocation {
17 readonly userId: string;
18 readonly environmentId: string;
19 readonly hostname: string;
20 readonly tunnelId: string | null;
21 readonly tunnelName: string;
22 readonly dnsRecordId: string | null;
23 readonly readyAt: string | null;
24 readonly origin: RelayManagedEndpointOrigin | null;
25 readonly updatedAt: string;
26 readonly generation: number;
27 /** When cleanup deleted the recorded tunnel; null once a tunnel is recorded again. */
28 readonly tunnelReleasedAt: string | null;
29}
30
31export interface ManagedEndpointTunnelAllocation extends ManagedEndpointAllocation {
32 readonly recoveryEnabled: boolean;
33}
34
35export const MANAGED_ENDPOINT_ALLOCATION_LOOKUP_BATCH_SIZE = 500;
36
37export function resolveReadyManagedEndpoint(input: {
38 readonly allocation: ManagedEndpointAllocation;
39 readonly baseDomain: string | undefined;
40}): RelayManagedEndpoint | null {
41 if (
42 !input.baseDomain ||
43 input.allocation.readyAt === null ||
44 input.allocation.tunnelId === null ||
45 input.allocation.dnsRecordId === null ||
46 !isManagedEndpointHostname(input.allocation.hostname, input.baseDomain)
47 ) {
48 return null;
49 }
50 return managedEndpointForHostname(input.allocation.hostname);
51}
52
53export class ManagedEndpointAllocationPersistenceError extends Schema.TaggedError<ManagedEndpointAllocationPersistenceError>()(
54 "ManagedEndpointAllocationPersistenceError",
55 {
56 operation: Schema.Literals([
57 "get",
58 "reserve",
59 "record-tunnel",
60 "record-dns",
61 "mark-ready",
62 "enable-recovery",
63 "list-tunnels",
64 "lock-tunnel",
65 "claim-release",
66 "claim-deprovision",
67 "remove",
68 "remove-claimed",
69 "get-by-tunnel-name",
70 ]),
71 stage: Schema.Literals(["database-request", "resolve-reservation"]),
72 userId: Schema.String,
73 environmentId: Schema.String,
74 hostname: Schema.optionalKey(Schema.String),
75 tunnelName: Schema.optionalKey(Schema.String),
76 tunnelId: Schema.optionalKey(Schema.String),
77 dnsRecordId: Schema.optionalKey(Schema.String),
78 cause: Schema.optional(Schema.Defect()),
79 },
80) {
81 override get message(): string {
82 return `Managed endpoint allocation '${this.operation}' failed during '${this.stage}' for user '${this.userId}', environment '${this.environmentId}'`;
83 }
84}
85
86interface ManagedEndpointAllocationKey {
87 readonly userId: string;
88 readonly environmentId: string;
89}
90
91interface ReserveManagedEndpointAllocationInput extends ManagedEndpointAllocationKey {
92 readonly hostname: string;
93 readonly tunnelName: string;
94}
95
96interface RecordManagedEndpointTunnelInput extends ManagedEndpointAllocationKey {
97 readonly tunnelId: string;
98 readonly generation: number;
99}
100
101interface RecordManagedEndpointDnsInput extends ManagedEndpointAllocationKey {
102 readonly dnsRecordId: string;
103 readonly tunnelId: string;
104 readonly generation: number;
105}
106
107interface MarkManagedEndpointReadyInput extends ManagedEndpointAllocationKey {
108 readonly tunnelId: string;
109 readonly generation: number;
110 readonly origin: RelayManagedEndpointOrigin;
111}
112
113interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey {
114 readonly tunnelId: string;
115 readonly generation: number;
116 /** Record that the tunnel is being deleted; set only by the claim that deletes it. */
117 readonly markReleased?: boolean;
118}
119
120interface EnableManagedEndpointRecoveryInput extends ManagedEndpointAllocationKey {
121 readonly tunnelId: string;
122 readonly environmentPublicKey: string;
123 readonly origin: RelayManagedEndpointOrigin;
124}
125
126interface ClaimManagedEndpointDeprovisionInput extends ManagedEndpointAllocationKey {
127 readonly generation: number;
128}
129
130interface RemoveClaimedManagedEndpointAllocationInput extends ManagedEndpointAllocationKey {
131 readonly generation: number;
132}
133
134export class ManagedEndpointAllocations extends Context.Service<
135 ManagedEndpointAllocations,
136 {
137 readonly get: (
138 input: ManagedEndpointAllocationKey,
139 ) => Effect.Effect<ManagedEndpointAllocation | null, ManagedEndpointAllocationPersistenceError>;
140 /** The allocation that owns a tunnel name; tunnel names are unique. */
141 readonly getByTunnelName: (
142 tunnelName: string,
143 ) => Effect.Effect<ManagedEndpointAllocation | null, ManagedEndpointAllocationPersistenceError>;
144 readonly reserve: (
145 input: ReserveManagedEndpointAllocationInput,
146 ) => Effect.Effect<ManagedEndpointAllocation, ManagedEndpointAllocationPersistenceError>;
147 readonly recordTunnel: (
148 input: RecordManagedEndpointTunnelInput,
149 ) => Effect.Effect<number | null, ManagedEndpointAllocationPersistenceError>;
150 readonly recordDns: (
151 input: RecordManagedEndpointDnsInput,
152 ) => Effect.Effect<number | null, ManagedEndpointAllocationPersistenceError>;
153 readonly markReady: (
154 input: MarkManagedEndpointReadyInput,
155 ) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>;
156 readonly enableRecovery: (
157 input: EnableManagedEndpointRecoveryInput,
158 ) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>;
159 readonly listByTunnelNames: (
160 tunnelNames: ReadonlyArray<string>,
161 ) => Effect.Effect<
162 ReadonlyArray<ManagedEndpointTunnelAllocation>,
163 ManagedEndpointAllocationPersistenceError
164 >;
165 /**
166 * Atomically claims the right to delete the allocation's tunnel: succeeds
167 * only while the recorded tunnel and generation still match what the
168 * caller loaded. A concurrent provision increments `generation` when it
169 * records its tunnel, which makes a stale claim fail and keeps the freshly
170 * issued tunnel alive.
171 */
172 readonly claimRelease: (
173 input: ClaimManagedEndpointReleaseInput,
174 ) => Effect.Effect<number | null, ManagedEndpointAllocationPersistenceError>;
175 readonly withClaimedTunnel: <A, E, R>(
176 input: ClaimManagedEndpointReleaseInput,
177 effect: Effect.Effect<A, E, R>,
178 ) => Effect.Effect<Option.Option<A>, E | ManagedEndpointAllocationPersistenceError, R>;
179 /**
180 * Claims the complete allocation for teardown only if its generation still
181 * matches the snapshot captured by the unlink operation.
182 *
183 * Returns the claim generation used by `removeClaimed`, or null when a
184 * concurrent provision has already superseded the snapshot.
185 */
186 readonly claimDeprovision: (
187 input: ClaimManagedEndpointDeprovisionInput,
188 ) => Effect.Effect<number | null, ManagedEndpointAllocationPersistenceError>;
189 readonly remove: (
190 input: ManagedEndpointAllocationKey,
191 ) => Effect.Effect<void, ManagedEndpointAllocationPersistenceError>;
192 readonly removeClaimed: (
193 input: RemoveClaimedManagedEndpointAllocationInput,
194 ) => Effect.Effect<boolean, ManagedEndpointAllocationPersistenceError>;
195 }
196>()("t3code-relay/environments/ManagedEndpointAllocations") {}
197
198const allocationSelection = {
199 userId: relayManagedEndpointAllocations.userId,
200 environmentId: relayManagedEndpointAllocations.environmentId,
201 hostname: relayManagedEndpointAllocations.hostname,
202 tunnelId: relayManagedEndpointAllocations.tunnelId,
203 tunnelName: relayManagedEndpointAllocations.tunnelName,
204 dnsRecordId: relayManagedEndpointAllocations.dnsRecordId,
205 readyAt: relayManagedEndpointAllocations.readyAt,
206 origin: relayManagedEndpointAllocations.origin,
207 updatedAt: relayManagedEndpointAllocations.updatedAt,
208 generation: relayManagedEndpointAllocations.generation,
209 tunnelReleasedAt: relayManagedEndpointAllocations.tunnelReleasedAt,
210};
211
212const whereAllocation = (input: ManagedEndpointAllocationKey) =>
213 and(
214 eq(relayManagedEndpointAllocations.userId, input.userId),
215 eq(relayManagedEndpointAllocations.environmentId, input.environmentId),
216 );
217
218export const make = Effect.gen(function* () {
219 const db = yield* RelayDb.RelayDb;
220
221 return ManagedEndpointAllocations.of({
222 get: Effect.fn("relay.managed_endpoint_allocations.get")(function* (
223 input: ManagedEndpointAllocationKey,
224 ) {
225 return yield* db
226 .select(allocationSelection)
227 .from(relayManagedEndpointAllocations)
228 .where(whereAllocation(input))
229 .limit(1)
230 .pipe(
231 Effect.map((rows) => rows[0] ?? null),
232 Effect.mapError(
233 (cause) =>
234 new ManagedEndpointAllocationPersistenceError({
235 operation: "get",
236 stage: "database-request",
237 ...input,
238 cause,
239 }),
240 ),
241 );
242 }),
243 getByTunnelName: Effect.fn("relay.managed_endpoint_allocations.get_by_tunnel_name")(function* (
244 tunnelName: string,
245 ) {
246 return yield* db
247 .select(allocationSelection)
248 .from(relayManagedEndpointAllocations)
249 .where(eq(relayManagedEndpointAllocations.tunnelName, tunnelName))
250 .limit(1)
251 .pipe(
252 Effect.map((rows) => rows[0] ?? null),
253 Effect.mapError(
254 (cause) =>
255 new ManagedEndpointAllocationPersistenceError({
256 operation: "get-by-tunnel-name",
257 stage: "database-request",
258 userId: "",
259 environmentId: "",
260 tunnelName,
261 cause,
262 }),
263 ),
264 );
265 }),
266 reserve: Effect.fn("relay.managed_endpoint_allocations.reserve")(function* (
267 input: ReserveManagedEndpointAllocationInput,
268 ) {
269 const now = DateTime.formatIso(yield* DateTime.now);
270 const inserted = yield* db
271 .insert(relayManagedEndpointAllocations)
272 .values({
273 ...input,
274 createdAt: now,
275 updatedAt: now,
276 })
277 .onConflictDoNothing()
278 .returning(allocationSelection)
279 .pipe(
280 Effect.mapError(
281 (cause) =>
282 new ManagedEndpointAllocationPersistenceError({
283 operation: "reserve",
284 stage: "database-request",
285 ...input,
286 cause,
287 }),
288 ),
289 );
290
291 const allocation =
292 inserted[0] ??
293 (yield* db
294 .select(allocationSelection)
295 .from(relayManagedEndpointAllocations)
296 .where(whereAllocation(input))
297 .limit(1)
298 .pipe(
299 Effect.map((rows) => rows[0]),
300 Effect.mapError(
301 (cause) =>
302 new ManagedEndpointAllocationPersistenceError({
303 operation: "reserve",
304 stage: "database-request",
305 ...input,
306 cause,
307 }),
308 ),
309 ));
310
311 if (allocation === undefined) {
312 return yield* new ManagedEndpointAllocationPersistenceError({
313 operation: "reserve",
314 stage: "resolve-reservation",
315 ...input,
316 });
317 }
318
319 return allocation;
320 }),
321 recordTunnel: Effect.fn("relay.managed_endpoint_allocations.record_tunnel")(function* (
322 input: RecordManagedEndpointTunnelInput,
323 ) {
324 return yield* db
325 .update(relayManagedEndpointAllocations)
326 .set({
327 tunnelId: input.tunnelId,
328 readyAt: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.readyAt} else null end`,
329 origin: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.origin} else null end`,
330 // Recovery registration is per tunnel: a replacement must register
331 // again before the reaper may treat it as recoverable.
332 recoveryEnabledAt: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnabledAt} else null end`,
333 recoveryEnvironmentPublicKey: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnvironmentPublicKey} else null end`,
334 tunnelReleasedAt: null,
335 updatedAt: DateTime.formatIso(yield* DateTime.now),
336 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
337 })
338 .where(
339 and(
340 whereAllocation(input),
341 eq(relayManagedEndpointAllocations.generation, input.generation),
342 ),
343 )
344 .returning({ generation: relayManagedEndpointAllocations.generation })
345 .pipe(
346 Effect.map((rows) => rows[0]?.generation ?? null),
347 Effect.mapError(
348 (cause) =>
349 new ManagedEndpointAllocationPersistenceError({
350 operation: "record-tunnel",
351 stage: "database-request",
352 ...input,
353 cause,
354 }),
355 ),
356 );
357 }),
358 recordDns: Effect.fn("relay.managed_endpoint_allocations.record_dns")(function* (
359 input: RecordManagedEndpointDnsInput,
360 ) {
361 return yield* db
362 .update(relayManagedEndpointAllocations)
363 .set({
364 dnsRecordId: input.dnsRecordId,
365 updatedAt: DateTime.formatIso(yield* DateTime.now),
366 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
367 })
368 .where(
369 and(
370 whereAllocation(input),
371 eq(relayManagedEndpointAllocations.tunnelId, input.tunnelId),
372 eq(relayManagedEndpointAllocations.generation, input.generation),
373 ),
374 )
375 .returning({ generation: relayManagedEndpointAllocations.generation })
376 .pipe(
377 Effect.map((rows) => rows[0]?.generation ?? null),
378 Effect.mapError(
379 (cause) =>
380 new ManagedEndpointAllocationPersistenceError({
381 operation: "record-dns",
382 stage: "database-request",
383 ...input,
384 cause,
385 }),
386 ),
387 );
388 }),
389 markReady: Effect.fn("relay.managed_endpoint_allocations.mark_ready")(function* (
390 input: MarkManagedEndpointReadyInput,
391 ) {
392 const now = DateTime.formatIso(yield* DateTime.now);
393 return yield* db
394 .update(relayManagedEndpointAllocations)
395 .set({
396 readyAt: now,
397 origin: input.origin,
398 updatedAt: now,
399 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
400 })
401 .where(
402 and(
403 whereAllocation(input),
404 eq(relayManagedEndpointAllocations.tunnelId, input.tunnelId),
405 eq(relayManagedEndpointAllocations.generation, input.generation),
406 ),
407 )
408 .returning({ environmentId: relayManagedEndpointAllocations.environmentId })
409 .pipe(
410 Effect.map((rows) => rows.length > 0),
411 Effect.mapError(
412 (cause) =>
413 new ManagedEndpointAllocationPersistenceError({
414 operation: "mark-ready",
415 stage: "database-request",
416 ...input,
417 cause,
418 }),
419 ),
420 );
421 }),
422 enableRecovery: Effect.fn("relay.managed_endpoint_allocations.enable_recovery")(function* (
423 input: EnableManagedEndpointRecoveryInput,
424 ) {
425 const now = DateTime.formatIso(yield* DateTime.now);
426 return yield* db
427 .update(relayManagedEndpointAllocations)
428 .set({
429 recoveryEnabledAt: now,
430 recoveryEnvironmentPublicKey: input.environmentPublicKey,
431 updatedAt: now,
432 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
433 })
434 .where(
435 and(
436 whereAllocation(input),
437 eq(relayManagedEndpointAllocations.tunnelId, input.tunnelId),
438 eq(relayManagedEndpointAllocations.origin, input.origin),
439 exists(
440 new QueryBuilder()
441 .select({ userId: relayEnvironmentLinks.userId })
442 .from(relayEnvironmentLinks)
443 .where(
444 and(
445 eq(relayEnvironmentLinks.userId, input.userId),
446 eq(relayEnvironmentLinks.environmentId, input.environmentId),
447 eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey),
448 eq(relayEnvironmentLinks.endpointProviderKind, "cloudflare_tunnel"),
449 isNull(relayEnvironmentLinks.revokedAt),
450 ),
451 )
452 .for("update"),
453 ),
454 ),
455 )
456 .returning({ environmentId: relayManagedEndpointAllocations.environmentId })
457 .pipe(
458 Effect.map((rows) => rows.length > 0),
459 Effect.mapError(
460 (cause) =>
461 new ManagedEndpointAllocationPersistenceError({
462 operation: "enable-recovery",
463 stage: "database-request",
464 ...input,
465 cause,
466 }),
467 ),
468 );
469 }),
470 listByTunnelNames: Effect.fn("relay.managed_endpoint_allocations.list_by_tunnel_names")(
471 function* (tunnelNames: ReadonlyArray<string>) {
472 if (tunnelNames.length === 0) {
473 return [];
474 }
475 const batches = Array.from(
476 { length: Math.ceil(tunnelNames.length / MANAGED_ENDPOINT_ALLOCATION_LOOKUP_BATCH_SIZE) },
477 (_, index) =>
478 tunnelNames.slice(
479 index * MANAGED_ENDPOINT_ALLOCATION_LOOKUP_BATCH_SIZE,
480 (index + 1) * MANAGED_ENDPOINT_ALLOCATION_LOOKUP_BATCH_SIZE,
481 ),
482 );
483 const results = yield* Effect.forEach(
484 batches,
485 (batch) =>
486 db
487 .select({
488 ...allocationSelection,
489 recoveryEnabledAt: relayManagedEndpointAllocations.recoveryEnabledAt,
490 recoveryEnvironmentPublicKey:
491 relayManagedEndpointAllocations.recoveryEnvironmentPublicKey,
492 linkedEnvironmentPublicKey: relayEnvironmentLinks.environmentPublicKey,
493 })
494 .from(relayManagedEndpointAllocations)
495 .leftJoin(
496 relayEnvironmentLinks,
497 and(
498 eq(relayEnvironmentLinks.userId, relayManagedEndpointAllocations.userId),
499 eq(
500 relayEnvironmentLinks.environmentId,
501 relayManagedEndpointAllocations.environmentId,
502 ),
503 isNull(relayEnvironmentLinks.revokedAt),
504 ),
505 )
506 .where(inArray(relayManagedEndpointAllocations.tunnelName, batch))
507 .pipe(
508 Effect.map((rows) =>
509 rows.map(
510 ({
511 recoveryEnabledAt,
512 recoveryEnvironmentPublicKey,
513 linkedEnvironmentPublicKey,
514 ...allocation
515 }) => ({
516 ...allocation,
517 recoveryEnabled:
518 recoveryEnabledAt !== null &&
519 recoveryEnvironmentPublicKey !== null &&
520 recoveryEnvironmentPublicKey === linkedEnvironmentPublicKey,
521 }),
522 ),
523 ),
524 Effect.mapError(
525 (cause) =>
526 new ManagedEndpointAllocationPersistenceError({
527 operation: "list-tunnels",
528 stage: "database-request",
529 userId: "*",
530 environmentId: "*",
531 cause,
532 }),
533 ),
534 ),
535 { concurrency: 1 },
536 );
537 return results.flat();
538 },
539 ),
540 claimRelease: Effect.fn("relay.managed_endpoint_allocations.claim_release")(function* (
541 input: ClaimManagedEndpointReleaseInput,
542 ) {
543 const now = DateTime.formatIso(yield* DateTime.now);
544 const claimed = yield* db
545 .update(relayManagedEndpointAllocations)
546 .set({
547 updatedAt: now,
548 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
549 ...(input.markReleased === true ? { tunnelReleasedAt: now } : {}),
550 })
551 .where(
552 and(
553 whereAllocation(input),
554 eq(relayManagedEndpointAllocations.tunnelId, input.tunnelId),
555 eq(relayManagedEndpointAllocations.generation, input.generation),
556 ),
557 )
558 .returning({ generation: relayManagedEndpointAllocations.generation })
559 .pipe(
560 Effect.map((rows) => rows[0]?.generation ?? null),
561 Effect.mapError(
562 (cause) =>
563 new ManagedEndpointAllocationPersistenceError({
564 operation: "claim-release",
565 stage: "database-request",
566 userId: input.userId,
567 environmentId: input.environmentId,
568 tunnelId: input.tunnelId,
569 cause,
570 }),
571 ),
572 );
573 return claimed;
574 }),
575 withClaimedTunnel: Effect.fn("relay.managed_endpoint_allocations.with_claimed_tunnel")(
576 function* <A, E, R>(
577 input: ClaimManagedEndpointReleaseInput,
578 effect: Effect.Effect<A, E, R>,
579 ): Effect.fn.Return<Option.Option<A>, E | ManagedEndpointAllocationPersistenceError, R> {
580 const lockError = (cause: unknown) =>
581 new ManagedEndpointAllocationPersistenceError({
582 operation: "lock-tunnel",
583 stage: "database-request",
584 userId: input.userId,
585 environmentId: input.environmentId,
586 tunnelId: input.tunnelId,
587 cause,
588 });
589 return yield* db.$client
590 .withTransaction(
591 db
592 .select({ generation: relayManagedEndpointAllocations.generation })
593 .from(relayManagedEndpointAllocations)
594 .where(
595 and(
596 whereAllocation(input),
597 eq(relayManagedEndpointAllocations.tunnelId, input.tunnelId),
598 eq(relayManagedEndpointAllocations.generation, input.generation),
599 ),
600 )
601 .limit(1)
602 .for("update")
603 .pipe(
604 Effect.mapError(lockError),
605 Effect.flatMap((rows) =>
606 rows.length === 0 ? Effect.succeedNone : Effect.asSome(effect),
607 ),
608 ),
609 )
610 .pipe(
611 Effect.mapError((cause) => (SqlError.isSqlError(cause) ? lockError(cause) : cause)),
612 );
613 },
614 ),
615 claimDeprovision: Effect.fn("relay.managed_endpoint_allocations.claim_deprovision")(function* (
616 input: ClaimManagedEndpointDeprovisionInput,
617 ) {
618 const claimed = yield* db
619 .update(relayManagedEndpointAllocations)
620 .set({
621 updatedAt: DateTime.formatIso(yield* DateTime.now),
622 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
623 })
624 .where(
625 and(
626 whereAllocation(input),
627 eq(relayManagedEndpointAllocations.generation, input.generation),
628 ),
629 )
630 .returning({ generation: relayManagedEndpointAllocations.generation })
631 .pipe(
632 Effect.map((rows) => rows[0]?.generation ?? null),
633 Effect.mapError(
634 (cause) =>
635 new ManagedEndpointAllocationPersistenceError({
636 operation: "claim-deprovision",
637 stage: "database-request",
638 userId: input.userId,
639 environmentId: input.environmentId,
640 cause,
641 }),
642 ),
643 );
644 return claimed;
645 }),
646 remove: Effect.fn("relay.managed_endpoint_allocations.remove")(function* (
647 input: ManagedEndpointAllocationKey,
648 ) {
649 yield* db
650 .delete(relayManagedEndpointAllocations)
651 .where(whereAllocation(input))
652 .pipe(
653 Effect.mapError(
654 (cause) =>
655 new ManagedEndpointAllocationPersistenceError({
656 operation: "remove",
657 stage: "database-request",
658 ...input,
659 cause,
660 }),
661 ),
662 );
663 }),
664 removeClaimed: Effect.fn("relay.managed_endpoint_allocations.remove_claimed")(function* (
665 input: RemoveClaimedManagedEndpointAllocationInput,
666 ) {
667 return yield* db
668 .delete(relayManagedEndpointAllocations)
669 .where(
670 and(
671 whereAllocation(input),
672 eq(relayManagedEndpointAllocations.generation, input.generation),
673 ),
674 )
675 .returning({ userId: relayManagedEndpointAllocations.userId })
676 .pipe(
677 Effect.map((rows) => rows.length > 0),
678 Effect.mapError(
679 (cause) =>
680 new ManagedEndpointAllocationPersistenceError({
681 operation: "remove-claimed",
682 stage: "database-request",
683 userId: input.userId,
684 environmentId: input.environmentId,
685 cause,
686 }),
687 ),
688 );
689 }),
690 });
691});
692
693export const layer = Layer.effect(ManagedEndpointAllocations, make);