authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 19:53:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 19:53:51-07:00
log6b456a9618c35f2fa525f6dfc9ef9b0ff01471b8
tree771c38bf62896c2460e0f2564c299b112672d00d
parent6bfc8baeb8f280e57ef6d0d514680b0bce8538a2
signature Signed by PGP key B5690EEEBB952194

feat(connect): tell users when an idle tunnel was removed (#13567)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

21 files changed, 1932 insertions(+), 24 deletions(-)

apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts+21
......@@ -2,6 +2,8 @@ import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contract
22import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay";
33import { describe, expect, it } from "vite-plus/test";
44
5import { RELAY_TUNNEL_RELEASED_MESSAGE } from "@t3tools/client-runtime/relay";
6
57import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation";
68
79function relayStatus(
......@@ -111,4 +113,23 @@ describe("available cloud environment presentation", () => {
111113 connectionErrorTraceId: "trace-status",
112114 });
113115 });
116
117 it("tells the user to update a host whose idle tunnel was removed", () => {
118 const status = {
119 ...relayStatus("offline", "Managed endpoint health request failed.", "trace-released"),
120 offlineReason: "tunnel_released",
121 } satisfies RelayEnvironmentStatusResponse;
122 expect(
123 availableCloudEnvironmentPresentation({
124 isStatusPending: false,
125 status,
126 statusError: null,
127 statusErrorTraceId: null,
128 }),
129 ).toMatchObject({
130 connectionState: "error",
131 statusText: RELAY_TUNNEL_RELEASED_MESSAGE,
132 connectionErrorTraceId: "trace-released",
133 });
134 });
114135});
apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts+3-1
......@@ -3,6 +3,7 @@ import {
33 orchestrationProtocolCompatibilityError,
44 type EnvironmentConnectionPhase,
55} from "@t3tools/client-runtime/connection";
6import { relayOfflineReasonMessage } from "@t3tools/client-runtime/relay";
67
78export interface AvailableCloudEnvironmentPresentation {
89 readonly connectionError: string | null;
......@@ -39,7 +40,8 @@ export function availableCloudEnvironmentPresentation(input: {
3940 }
4041
4142 if (input.status?.status === "offline") {
42 const connectionError = input.status.error ?? "Relay is offline.";
43 const connectionError =
44 relayOfflineReasonMessage(input.status) ?? input.status.error ?? "Relay is offline.";
4345 return {
4446 connectionError,
4547 connectionErrorTraceId: input.status.traceId ?? null,
apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx+35-1
......@@ -1,4 +1,4 @@
1import type { Discovery } from "@t3tools/client-runtime/relay";
1import { RELAY_TUNNEL_RELEASED_MESSAGE, type Discovery } from "@t3tools/client-runtime/relay";
22import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
33import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts";
44import * as Option from "effect/Option";
......@@ -152,6 +152,40 @@ afterEach(async () => {
152152 vi.unstubAllGlobals();
153153});
154154
155describe("cloud environment offline reasons", () => {
156 it("tells the user to update a computer whose idle tunnel was removed", async () => {
157 const base = linkedMachines.get(newMachineId)!;
158 const offlineEntry = (offlineReason?: "tunnel_released") => ({
159 ...base,
160 availability: "offline" as const,
161 status: Option.some({
162 environmentId: newMachineId,
163 endpoint: base.environment.endpoint,
164 status: "offline" as const,
165 checkedAt: "2026-09-15T00:00:00Z",
166 error: "Managed endpoint health request failed.",
167 ...(offlineReason ? { offlineReason } : {}),
168 }),
169 });
170 const rowText = () =>
171 renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children);
172
173 discovery.listEnvironments.mockResolvedValue(new Map([[newMachineId, offlineEntry()]]));
174 await mount();
175 expect(rowText()).toContain("T3 Connect · Not added · Relay offline");
176
177 await act(async () => {
178 publish({
179 environments: new Map([[newMachineId, offlineEntry("tunnel_released")]]),
180 refreshing: false,
181 offline: false,
182 error: Option.none(),
183 });
184 });
185 expect(rowText()).toContain(RELAY_TUNNEL_RELEASED_MESSAGE);
186 });
187});
188
155189describe("cloud onboarding discovery", () => {
156190 it("signals that the section can expand after initial discovery settles", async () => {
157191 let finishDiscovery!: (environments: DiscoveredEnvironments) => void;
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx+32-20
......@@ -5,6 +5,7 @@ import {
55 RelayConnectionTarget,
66 orchestrationProtocolCompatibilityError,
77} from "@t3tools/client-runtime/connection";
8import { relayOfflineReasonMessage } from "@t3tools/client-runtime/relay";
89import {
910 isAtomCommandInterrupted,
1011 squashAtomCommandFailure,
......@@ -335,7 +336,14 @@ export function CloudEnvironmentConnectRows({
335336 // A connected machine's own config (with the user's icon pick) wins. Before
336337 // that, the relay's health probe already carries the server's descriptor, so
337338 // a machine can wear its detected glyph before this device ever connects.
338 const descriptor = status === undefined ? undefined : Option.getOrNull(status)?.descriptor;
339 const relayStatus = status === undefined ? null : Option.getOrNull(status);
340 const descriptor = relayStatus?.descriptor;
341 // Why the relay reports this environment offline, when it knows more than
342 // "no answer". Shown for saved and unsaved rows alike.
343 const offlineReason =
344 availability === "offline" && relayStatus !== null
345 ? relayOfflineReasonMessage(relayStatus)
346 : null;
339347 const machineKind = resolveEnvironmentMachineKind(
340348 savedEnvironment?.serverConfig ??
341349 (descriptor === undefined ? null : { environment: descriptor }),
......@@ -361,16 +369,18 @@ export function CloudEnvironmentConnectRows({
361369 const statusText =
362370 unsupported && !savedEnvironment
363371 ? `T3 Connect · ${notAdded} · Client not supported`
364 : savedConnection
365 ? savedConnection.statusText
366 : availability === "online"
367 ? `T3 Connect · ${notAdded} · Relay online`
368 : availability === "offline"
369 ? `T3 Connect · ${notAdded} · Relay offline`
370 : availability === "checking"
371 ? `T3 Connect · ${notAdded} · Checking relay status…`
372 : (Option.getOrNull(error)?.message ??
373 `T3 Connect · ${notAdded} · Relay status unavailable`);
372 : offlineReason !== null
373 ? offlineReason
374 : savedConnection
375 ? savedConnection.statusText
376 : availability === "online"
377 ? `T3 Connect · ${notAdded} · Relay online`
378 : availability === "offline"
379 ? `T3 Connect · ${notAdded} · Relay offline`
380 : availability === "checking"
381 ? `T3 Connect · ${notAdded} · Checking relay status…`
382 : (Option.getOrNull(error)?.message ??
383 `T3 Connect · ${notAdded} · Relay status unavailable`);
374384 if (selection) {
375385 return (
376386 <label
......@@ -435,15 +445,17 @@ export function CloudEnvironmentConnectRows({
435445 tooltipText={
436446 unsupportedDetail !== null
437447 ? unsupportedDetail
438 : savedConnection
439 ? savedConnection.statusText
440 : availability === "online"
441 ? "Relay online"
442 : availability === "offline"
443 ? "Relay offline"
444 : availability === "checking"
445 ? "Checking relay status"
446 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
448 : offlineReason !== null
449 ? offlineReason
450 : savedConnection
451 ? savedConnection.statusText
452 : availability === "online"
453 ? "Relay online"
454 : availability === "offline"
455 ? "Relay offline"
456 : availability === "checking"
457 ? "Checking relay status"
458 : (Option.getOrNull(error)?.message ?? "Relay status unavailable")
447459 }
448460 />
449461 <EnvironmentMachineIcon
docs/user/remote-access.md+5
......@@ -194,6 +194,11 @@ and keeps the same address. When the host starts again or wakes, T3 Connect
194194creates a replacement tunnel on its own. You do not need to pair again. Cleanup
195195usually runs five to ten minutes after the tunnel goes down.
196196
197T3 Connect also removes the tunnel of an environment running an older version of
198T3 Code once it has been offline for seven days. That environment shows a message
199asking you to update. Start T3 Code on that computer and update it to the latest
200version; it reconnects at the same address without pairing again.
201
197202On a command-line host, `t3 connect unlink` disables exposure while retaining
198203your login; `t3 connect logout` also clears that login. Background-service
199204[removal](./background-service.md#manage-the-service) is separate.
infra/relay/migrations/postgres/20261005232034_managed_endpoint_tunnel_released_at/migration.sql created+1
......@@ -0,0 +1 @@
1ALTER TABLE "relay_managed_endpoint_allocations" ADD COLUMN "tunnel_released_at" varchar(64);
\ No newline at end of file
infra/relay/migrations/postgres/20261005232034_managed_endpoint_tunnel_released_at/snapshot.json created+1594
......@@ -0,0 +1,1594 @@
1{
2 "version": "8",
3 "dialect": "postgres",
4 "id": "7b10c420-1566-4dfc-8e9c-c73efa561d3b",
5 "prevIds": ["13972659-45db-4ce9-9818-523b450bceca"],
6 "ddl": [
7 {
8 "isRlsEnabled": false,
9 "name": "relay_agent_activity_rows",
10 "entityType": "tables",
11 "schema": "public"
12 },
13 {
14 "isRlsEnabled": false,
15 "name": "relay_delivery_attempts",
16 "entityType": "tables",
17 "schema": "public"
18 },
19 {
20 "isRlsEnabled": false,
21 "name": "relay_dpop_proofs",
22 "entityType": "tables",
23 "schema": "public"
24 },
25 {
26 "isRlsEnabled": false,
27 "name": "relay_environment_credentials",
28 "entityType": "tables",
29 "schema": "public"
30 },
31 {
32 "isRlsEnabled": false,
33 "name": "relay_environment_links",
34 "entityType": "tables",
35 "schema": "public"
36 },
37 {
38 "isRlsEnabled": false,
39 "name": "relay_live_activities",
40 "entityType": "tables",
41 "schema": "public"
42 },
43 {
44 "isRlsEnabled": false,
45 "name": "relay_managed_endpoint_allocations",
46 "entityType": "tables",
47 "schema": "public"
48 },
49 {
50 "isRlsEnabled": false,
51 "name": "relay_managed_tunnel_limits",
52 "entityType": "tables",
53 "schema": "public"
54 },
55 {
56 "isRlsEnabled": false,
57 "name": "relay_mobile_devices",
58 "entityType": "tables",
59 "schema": "public"
60 },
61 {
62 "type": "varchar(191)",
63 "typeSchema": null,
64 "notNull": true,
65 "dimensions": 0,
66 "default": null,
67 "generated": null,
68 "identity": null,
69 "name": "environment_id",
70 "entityType": "columns",
71 "schema": "public",
72 "table": "relay_agent_activity_rows"
73 },
74 {
75 "type": "text",
76 "typeSchema": null,
77 "notNull": true,
78 "dimensions": 0,
79 "default": null,
80 "generated": null,
81 "identity": null,
82 "name": "environment_public_key",
83 "entityType": "columns",
84 "schema": "public",
85 "table": "relay_agent_activity_rows"
86 },
87 {
88 "type": "varchar(512)",
89 "typeSchema": null,
90 "notNull": true,
91 "dimensions": 0,
92 "default": null,
93 "generated": null,
94 "identity": null,
95 "name": "thread_id",
96 "entityType": "columns",
97 "schema": "public",
98 "table": "relay_agent_activity_rows"
99 },
100 {
101 "type": "jsonb",
102 "typeSchema": null,
103 "notNull": true,
104 "dimensions": 0,
105 "default": null,
106 "generated": null,
107 "identity": null,
108 "name": "state_json",
109 "entityType": "columns",
110 "schema": "public",
111 "table": "relay_agent_activity_rows"
112 },
113 {
114 "type": "varchar(64)",
115 "typeSchema": null,
116 "notNull": true,
117 "dimensions": 0,
118 "default": null,
119 "generated": null,
120 "identity": null,
121 "name": "updated_at",
122 "entityType": "columns",
123 "schema": "public",
124 "table": "relay_agent_activity_rows"
125 },
126 {
127 "type": "varchar(64)",
128 "typeSchema": null,
129 "notNull": true,
130 "dimensions": 0,
131 "default": null,
132 "generated": null,
133 "identity": null,
134 "name": "created_at",
135 "entityType": "columns",
136 "schema": "public",
137 "table": "relay_agent_activity_rows"
138 },
139 {
140 "type": "varchar(36)",
141 "typeSchema": null,
142 "notNull": true,
143 "dimensions": 0,
144 "default": null,
145 "generated": null,
146 "identity": null,
147 "name": "id",
148 "entityType": "columns",
149 "schema": "public",
150 "table": "relay_delivery_attempts"
151 },
152 {
153 "type": "varchar(64)",
154 "typeSchema": null,
155 "notNull": true,
156 "dimensions": 0,
157 "default": null,
158 "generated": null,
159 "identity": null,
160 "name": "created_at",
161 "entityType": "columns",
162 "schema": "public",
163 "table": "relay_delivery_attempts"
164 },
165 {
166 "type": "varchar(255)",
167 "typeSchema": null,
168 "notNull": false,
169 "dimensions": 0,
170 "default": null,
171 "generated": null,
172 "identity": null,
173 "name": "user_id",
174 "entityType": "columns",
175 "schema": "public",
176 "table": "relay_delivery_attempts"
177 },
178 {
179 "type": "varchar(191)",
180 "typeSchema": null,
181 "notNull": false,
182 "dimensions": 0,
183 "default": null,
184 "generated": null,
185 "identity": null,
186 "name": "environment_id",
187 "entityType": "columns",
188 "schema": "public",
189 "table": "relay_delivery_attempts"
190 },
191 {
192 "type": "varchar(512)",
193 "typeSchema": null,
194 "notNull": false,
195 "dimensions": 0,
196 "default": null,
197 "generated": null,
198 "identity": null,
199 "name": "thread_id",
200 "entityType": "columns",
201 "schema": "public",
202 "table": "relay_delivery_attempts"
203 },
204 {
205 "type": "varchar(255)",
206 "typeSchema": null,
207 "notNull": false,
208 "dimensions": 0,
209 "default": null,
210 "generated": null,
211 "identity": null,
212 "name": "device_id",
213 "entityType": "columns",
214 "schema": "public",
215 "table": "relay_delivery_attempts"
216 },
217 {
218 "type": "varchar(64)",
219 "typeSchema": null,
220 "notNull": true,
221 "dimensions": 0,
222 "default": null,
223 "generated": null,
224 "identity": null,
225 "name": "kind",
226 "entityType": "columns",
227 "schema": "public",
228 "table": "relay_delivery_attempts"
229 },
230 {
231 "type": "varchar(64)",
232 "typeSchema": null,
233 "notNull": false,
234 "dimensions": 0,
235 "default": null,
236 "generated": null,
237 "identity": null,
238 "name": "source_job_id",
239 "entityType": "columns",
240 "schema": "public",
241 "table": "relay_delivery_attempts"
242 },
243 {
244 "type": "varchar(16)",
245 "typeSchema": null,
246 "notNull": false,
247 "dimensions": 0,
248 "default": null,
249 "generated": null,
250 "identity": null,
251 "name": "token_suffix",
252 "entityType": "columns",
253 "schema": "public",
254 "table": "relay_delivery_attempts"
255 },
256 {
257 "type": "integer",
258 "typeSchema": null,
259 "notNull": false,
260 "dimensions": 0,
261 "default": null,
262 "generated": null,
263 "identity": null,
264 "name": "apns_status",
265 "entityType": "columns",
266 "schema": "public",
267 "table": "relay_delivery_attempts"
268 },
269 {
270 "type": "text",
271 "typeSchema": null,
272 "notNull": false,
273 "dimensions": 0,
274 "default": null,
275 "generated": null,
276 "identity": null,
277 "name": "apns_reason",
278 "entityType": "columns",
279 "schema": "public",
280 "table": "relay_delivery_attempts"
281 },
282 {
283 "type": "varchar(128)",
284 "typeSchema": null,
285 "notNull": false,
286 "dimensions": 0,
287 "default": null,
288 "generated": null,
289 "identity": null,
290 "name": "apns_id",
291 "entityType": "columns",
292 "schema": "public",
293 "table": "relay_delivery_attempts"
294 },
295 {
296 "type": "text",
297 "typeSchema": null,
298 "notNull": false,
299 "dimensions": 0,
300 "default": null,
301 "generated": null,
302 "identity": null,
303 "name": "transport_error",
304 "entityType": "columns",
305 "schema": "public",
306 "table": "relay_delivery_attempts"
307 },
308 {
309 "type": "varchar(128)",
310 "typeSchema": null,
311 "notNull": true,
312 "dimensions": 0,
313 "default": null,
314 "generated": null,
315 "identity": null,
316 "name": "thumbprint",
317 "entityType": "columns",
318 "schema": "public",
319 "table": "relay_dpop_proofs"
320 },
321 {
322 "type": "varchar(255)",
323 "typeSchema": null,
324 "notNull": true,
325 "dimensions": 0,
326 "default": null,
327 "generated": null,
328 "identity": null,
329 "name": "jti",
330 "entityType": "columns",
331 "schema": "public",
332 "table": "relay_dpop_proofs"
333 },
334 {
335 "type": "integer",
336 "typeSchema": null,
337 "notNull": true,
338 "dimensions": 0,
339 "default": null,
340 "generated": null,
341 "identity": null,
342 "name": "iat",
343 "entityType": "columns",
344 "schema": "public",
345 "table": "relay_dpop_proofs"
346 },
347 {
348 "type": "varchar(64)",
349 "typeSchema": null,
350 "notNull": true,
351 "dimensions": 0,
352 "default": null,
353 "generated": null,
354 "identity": null,
355 "name": "expires_at",
356 "entityType": "columns",
357 "schema": "public",
358 "table": "relay_dpop_proofs"
359 },
360 {
361 "type": "varchar(64)",
362 "typeSchema": null,
363 "notNull": true,
364 "dimensions": 0,
365 "default": null,
366 "generated": null,
367 "identity": null,
368 "name": "created_at",
369 "entityType": "columns",
370 "schema": "public",
371 "table": "relay_dpop_proofs"
372 },
373 {
374 "type": "varchar(64)",
375 "typeSchema": null,
376 "notNull": true,
377 "dimensions": 0,
378 "default": null,
379 "generated": null,
380 "identity": null,
381 "name": "credential_id",
382 "entityType": "columns",
383 "schema": "public",
384 "table": "relay_environment_credentials"
385 },
386 {
387 "type": "varchar(191)",
388 "typeSchema": null,
389 "notNull": true,
390 "dimensions": 0,
391 "default": null,
392 "generated": null,
393 "identity": null,
394 "name": "environment_id",
395 "entityType": "columns",
396 "schema": "public",
397 "table": "relay_environment_credentials"
398 },
399 {
400 "type": "text",
401 "typeSchema": null,
402 "notNull": true,
403 "dimensions": 0,
404 "default": null,
405 "generated": null,
406 "identity": null,
407 "name": "environment_public_key",
408 "entityType": "columns",
409 "schema": "public",
410 "table": "relay_environment_credentials"
411 },
412 {
413 "type": "varchar(191)",
414 "typeSchema": null,
415 "notNull": true,
416 "dimensions": 0,
417 "default": null,
418 "generated": null,
419 "identity": null,
420 "name": "credential_hash",
421 "entityType": "columns",
422 "schema": "public",
423 "table": "relay_environment_credentials"
424 },
425 {
426 "type": "varchar(64)",
427 "typeSchema": null,
428 "notNull": false,
429 "dimensions": 0,
430 "default": null,
431 "generated": null,
432 "identity": null,
433 "name": "revoked_at",
434 "entityType": "columns",
435 "schema": "public",
436 "table": "relay_environment_credentials"
437 },
438 {
439 "type": "varchar(64)",
440 "typeSchema": null,
441 "notNull": true,
442 "dimensions": 0,
443 "default": null,
444 "generated": null,
445 "identity": null,
446 "name": "created_at",
447 "entityType": "columns",
448 "schema": "public",
449 "table": "relay_environment_credentials"
450 },
451 {
452 "type": "varchar(64)",
453 "typeSchema": null,
454 "notNull": true,
455 "dimensions": 0,
456 "default": null,
457 "generated": null,
458 "identity": null,
459 "name": "updated_at",
460 "entityType": "columns",
461 "schema": "public",
462 "table": "relay_environment_credentials"
463 },
464 {
465 "type": "varchar(191)",
466 "typeSchema": null,
467 "notNull": true,
468 "dimensions": 0,
469 "default": null,
470 "generated": null,
471 "identity": null,
472 "name": "user_id",
473 "entityType": "columns",
474 "schema": "public",
475 "table": "relay_environment_links"
476 },
477 {
478 "type": "varchar(191)",
479 "typeSchema": null,
480 "notNull": true,
481 "dimensions": 0,
482 "default": null,
483 "generated": null,
484 "identity": null,
485 "name": "environment_id",
486 "entityType": "columns",
487 "schema": "public",
488 "table": "relay_environment_links"
489 },
490 {
491 "type": "text",
492 "typeSchema": null,
493 "notNull": true,
494 "dimensions": 0,
495 "default": "'T3 Environment'",
496 "generated": null,
497 "identity": null,
498 "name": "environment_label",
499 "entityType": "columns",
500 "schema": "public",
501 "table": "relay_environment_links"
502 },
503 {
504 "type": "text",
505 "typeSchema": null,
506 "notNull": true,
507 "dimensions": 0,
508 "default": null,
509 "generated": null,
510 "identity": null,
511 "name": "environment_public_key",
512 "entityType": "columns",
513 "schema": "public",
514 "table": "relay_environment_links"
515 },
516 {
517 "type": "text",
518 "typeSchema": null,
519 "notNull": true,
520 "dimensions": 0,
521 "default": null,
522 "generated": null,
523 "identity": null,
524 "name": "endpoint_http_base_url",
525 "entityType": "columns",
526 "schema": "public",
527 "table": "relay_environment_links"
528 },
529 {
530 "type": "text",
531 "typeSchema": null,
532 "notNull": true,
533 "dimensions": 0,
534 "default": null,
535 "generated": null,
536 "identity": null,
537 "name": "endpoint_ws_base_url",
538 "entityType": "columns",
539 "schema": "public",
540 "table": "relay_environment_links"
541 },
542 {
543 "type": "varchar(32)",
544 "typeSchema": null,
545 "notNull": true,
546 "dimensions": 0,
547 "default": null,
548 "generated": null,
549 "identity": null,
550 "name": "endpoint_provider_kind",
551 "entityType": "columns",
552 "schema": "public",
553 "table": "relay_environment_links"
554 },
555 {
556 "type": "boolean",
557 "typeSchema": null,
558 "notNull": true,
559 "dimensions": 0,
560 "default": "true",
561 "generated": null,
562 "identity": null,
563 "name": "notifications_enabled",
564 "entityType": "columns",
565 "schema": "public",
566 "table": "relay_environment_links"
567 },
568 {
569 "type": "boolean",
570 "typeSchema": null,
571 "notNull": true,
572 "dimensions": 0,
573 "default": "true",
574 "generated": null,
575 "identity": null,
576 "name": "live_activities_enabled",
577 "entityType": "columns",
578 "schema": "public",
579 "table": "relay_environment_links"
580 },
581 {
582 "type": "boolean",
583 "typeSchema": null,
584 "notNull": true,
585 "dimensions": 0,
586 "default": "false",
587 "generated": null,
588 "identity": null,
589 "name": "managed_tunnels_enabled",
590 "entityType": "columns",
591 "schema": "public",
592 "table": "relay_environment_links"
593 },
594 {
595 "type": "boolean",
596 "typeSchema": null,
597 "notNull": true,
598 "dimensions": 0,
599 "default": "false",
600 "generated": null,
601 "identity": null,
602 "name": "hold_webhooks_while_offline",
603 "entityType": "columns",
604 "schema": "public",
605 "table": "relay_environment_links"
606 },
607 {
608 "type": "varchar(191)",
609 "typeSchema": null,
610 "notNull": false,
611 "dimensions": 0,
612 "default": null,
613 "generated": null,
614 "identity": null,
615 "name": "created_by_device_id",
616 "entityType": "columns",
617 "schema": "public",
618 "table": "relay_environment_links"
619 },
620 {
621 "type": "varchar(64)",
622 "typeSchema": null,
623 "notNull": false,
624 "dimensions": 0,
625 "default": null,
626 "generated": null,
627 "identity": null,
628 "name": "revoked_at",
629 "entityType": "columns",
630 "schema": "public",
631 "table": "relay_environment_links"
632 },
633 {
634 "type": "varchar(64)",
635 "typeSchema": null,
636 "notNull": true,
637 "dimensions": 0,
638 "default": null,
639 "generated": null,
640 "identity": null,
641 "name": "created_at",
642 "entityType": "columns",
643 "schema": "public",
644 "table": "relay_environment_links"
645 },
646 {
647 "type": "varchar(64)",
648 "typeSchema": null,
649 "notNull": true,
650 "dimensions": 0,
651 "default": null,
652 "generated": null,
653 "identity": null,
654 "name": "updated_at",
655 "entityType": "columns",
656 "schema": "public",
657 "table": "relay_environment_links"
658 },
659 {
660 "type": "varchar(255)",
661 "typeSchema": null,
662 "notNull": true,
663 "dimensions": 0,
664 "default": null,
665 "generated": null,
666 "identity": null,
667 "name": "user_id",
668 "entityType": "columns",
669 "schema": "public",
670 "table": "relay_live_activities"
671 },
672 {
673 "type": "varchar(255)",
674 "typeSchema": null,
675 "notNull": true,
676 "dimensions": 0,
677 "default": null,
678 "generated": null,
679 "identity": null,
680 "name": "device_id",
681 "entityType": "columns",
682 "schema": "public",
683 "table": "relay_live_activities"
684 },
685 {
686 "type": "text",
687 "typeSchema": null,
688 "notNull": false,
689 "dimensions": 0,
690 "default": null,
691 "generated": null,
692 "identity": null,
693 "name": "activity_push_token",
694 "entityType": "columns",
695 "schema": "public",
696 "table": "relay_live_activities"
697 },
698 {
699 "type": "varchar(64)",
700 "typeSchema": null,
701 "notNull": false,
702 "dimensions": 0,
703 "default": null,
704 "generated": null,
705 "identity": null,
706 "name": "remote_start_queued_at",
707 "entityType": "columns",
708 "schema": "public",
709 "table": "relay_live_activities"
710 },
711 {
712 "type": "varchar(64)",
713 "typeSchema": null,
714 "notNull": false,
715 "dimensions": 0,
716 "default": null,
717 "generated": null,
718 "identity": null,
719 "name": "remote_started_at",
720 "entityType": "columns",
721 "schema": "public",
722 "table": "relay_live_activities"
723 },
724 {
725 "type": "varchar(64)",
726 "typeSchema": null,
727 "notNull": false,
728 "dimensions": 0,
729 "default": null,
730 "generated": null,
731 "identity": null,
732 "name": "ended_at",
733 "entityType": "columns",
734 "schema": "public",
735 "table": "relay_live_activities"
736 },
737 {
738 "type": "jsonb",
739 "typeSchema": null,
740 "notNull": false,
741 "dimensions": 0,
742 "default": null,
743 "generated": null,
744 "identity": null,
745 "name": "last_aggregate_json",
746 "entityType": "columns",
747 "schema": "public",
748 "table": "relay_live_activities"
749 },
750 {
751 "type": "varchar(64)",
752 "typeSchema": null,
753 "notNull": false,
754 "dimensions": 0,
755 "default": null,
756 "generated": null,
757 "identity": null,
758 "name": "last_live_activity_delivery_at",
759 "entityType": "columns",
760 "schema": "public",
761 "table": "relay_live_activities"
762 },
763 {
764 "type": "varchar(64)",
765 "typeSchema": null,
766 "notNull": true,
767 "dimensions": 0,
768 "default": null,
769 "generated": null,
770 "identity": null,
771 "name": "created_at",
772 "entityType": "columns",
773 "schema": "public",
774 "table": "relay_live_activities"
775 },
776 {
777 "type": "varchar(64)",
778 "typeSchema": null,
779 "notNull": true,
780 "dimensions": 0,
781 "default": null,
782 "generated": null,
783 "identity": null,
784 "name": "updated_at",
785 "entityType": "columns",
786 "schema": "public",
787 "table": "relay_live_activities"
788 },
789 {
790 "type": "varchar(191)",
791 "typeSchema": null,
792 "notNull": true,
793 "dimensions": 0,
794 "default": null,
795 "generated": null,
796 "identity": null,
797 "name": "user_id",
798 "entityType": "columns",
799 "schema": "public",
800 "table": "relay_managed_endpoint_allocations"
801 },
802 {
803 "type": "varchar(191)",
804 "typeSchema": null,
805 "notNull": true,
806 "dimensions": 0,
807 "default": null,
808 "generated": null,
809 "identity": null,
810 "name": "environment_id",
811 "entityType": "columns",
812 "schema": "public",
813 "table": "relay_managed_endpoint_allocations"
814 },
815 {
816 "type": "text",
817 "typeSchema": null,
818 "notNull": true,
819 "dimensions": 0,
820 "default": null,
821 "generated": null,
822 "identity": null,
823 "name": "hostname",
824 "entityType": "columns",
825 "schema": "public",
826 "table": "relay_managed_endpoint_allocations"
827 },
828 {
829 "type": "varchar(191)",
830 "typeSchema": null,
831 "notNull": false,
832 "dimensions": 0,
833 "default": null,
834 "generated": null,
835 "identity": null,
836 "name": "tunnel_id",
837 "entityType": "columns",
838 "schema": "public",
839 "table": "relay_managed_endpoint_allocations"
840 },
841 {
842 "type": "text",
843 "typeSchema": null,
844 "notNull": true,
845 "dimensions": 0,
846 "default": null,
847 "generated": null,
848 "identity": null,
849 "name": "tunnel_name",
850 "entityType": "columns",
851 "schema": "public",
852 "table": "relay_managed_endpoint_allocations"
853 },
854 {
855 "type": "varchar(191)",
856 "typeSchema": null,
857 "notNull": false,
858 "dimensions": 0,
859 "default": null,
860 "generated": null,
861 "identity": null,
862 "name": "dns_record_id",
863 "entityType": "columns",
864 "schema": "public",
865 "table": "relay_managed_endpoint_allocations"
866 },
867 {
868 "type": "varchar(64)",
869 "typeSchema": null,
870 "notNull": false,
871 "dimensions": 0,
872 "default": null,
873 "generated": null,
874 "identity": null,
875 "name": "ready_at",
876 "entityType": "columns",
877 "schema": "public",
878 "table": "relay_managed_endpoint_allocations"
879 },
880 {
881 "type": "varchar(64)",
882 "typeSchema": null,
883 "notNull": false,
884 "dimensions": 0,
885 "default": null,
886 "generated": null,
887 "identity": null,
888 "name": "recovery_enabled_at",
889 "entityType": "columns",
890 "schema": "public",
891 "table": "relay_managed_endpoint_allocations"
892 },
893 {
894 "type": "text",
895 "typeSchema": null,
896 "notNull": false,
897 "dimensions": 0,
898 "default": null,
899 "generated": null,
900 "identity": null,
901 "name": "recovery_environment_public_key",
902 "entityType": "columns",
903 "schema": "public",
904 "table": "relay_managed_endpoint_allocations"
905 },
906 {
907 "type": "varchar(64)",
908 "typeSchema": null,
909 "notNull": false,
910 "dimensions": 0,
911 "default": null,
912 "generated": null,
913 "identity": null,
914 "name": "tunnel_released_at",
915 "entityType": "columns",
916 "schema": "public",
917 "table": "relay_managed_endpoint_allocations"
918 },
919 {
920 "type": "jsonb",
921 "typeSchema": null,
922 "notNull": false,
923 "dimensions": 0,
924 "default": null,
925 "generated": null,
926 "identity": null,
927 "name": "origin",
928 "entityType": "columns",
929 "schema": "public",
930 "table": "relay_managed_endpoint_allocations"
931 },
932 {
933 "type": "integer",
934 "typeSchema": null,
935 "notNull": true,
936 "dimensions": 0,
937 "default": "0",
938 "generated": null,
939 "identity": null,
940 "name": "generation",
941 "entityType": "columns",
942 "schema": "public",
943 "table": "relay_managed_endpoint_allocations"
944 },
945 {
946 "type": "varchar(64)",
947 "typeSchema": null,
948 "notNull": true,
949 "dimensions": 0,
950 "default": null,
951 "generated": null,
952 "identity": null,
953 "name": "created_at",
954 "entityType": "columns",
955 "schema": "public",
956 "table": "relay_managed_endpoint_allocations"
957 },
958 {
959 "type": "varchar(64)",
960 "typeSchema": null,
961 "notNull": true,
962 "dimensions": 0,
963 "default": null,
964 "generated": null,
965 "identity": null,
966 "name": "updated_at",
967 "entityType": "columns",
968 "schema": "public",
969 "table": "relay_managed_endpoint_allocations"
970 },
971 {
972 "type": "varchar(191)",
973 "typeSchema": null,
974 "notNull": true,
975 "dimensions": 0,
976 "default": null,
977 "generated": null,
978 "identity": null,
979 "name": "user_id",
980 "entityType": "columns",
981 "schema": "public",
982 "table": "relay_managed_tunnel_limits"
983 },
984 {
985 "type": "integer",
986 "typeSchema": null,
987 "notNull": true,
988 "dimensions": 0,
989 "default": null,
990 "generated": null,
991 "identity": null,
992 "name": "max_tunnels",
993 "entityType": "columns",
994 "schema": "public",
995 "table": "relay_managed_tunnel_limits"
996 },
997 {
998 "type": "varchar(64)",
999 "typeSchema": null,
1000 "notNull": true,
1001 "dimensions": 0,
1002 "default": null,
1003 "generated": null,
1004 "identity": null,
1005 "name": "created_at",
1006 "entityType": "columns",
1007 "schema": "public",
1008 "table": "relay_managed_tunnel_limits"
1009 },
1010 {
1011 "type": "varchar(64)",
1012 "typeSchema": null,
1013 "notNull": true,
1014 "dimensions": 0,
1015 "default": null,
1016 "generated": null,
1017 "identity": null,
1018 "name": "updated_at",
1019 "entityType": "columns",
1020 "schema": "public",
1021 "table": "relay_managed_tunnel_limits"
1022 },
1023 {
1024 "type": "varchar(255)",
1025 "typeSchema": null,
1026 "notNull": true,
1027 "dimensions": 0,
1028 "default": null,
1029 "generated": null,
1030 "identity": null,
1031 "name": "user_id",
1032 "entityType": "columns",
1033 "schema": "public",
1034 "table": "relay_mobile_devices"
1035 },
1036 {
1037 "type": "varchar(255)",
1038 "typeSchema": null,
1039 "notNull": true,
1040 "dimensions": 0,
1041 "default": null,
1042 "generated": null,
1043 "identity": null,
1044 "name": "device_id",
1045 "entityType": "columns",
1046 "schema": "public",
1047 "table": "relay_mobile_devices"
1048 },
1049 {
1050 "type": "text",
1051 "typeSchema": null,
1052 "notNull": true,
1053 "dimensions": 0,
1054 "default": "'iOS device'",
1055 "generated": null,
1056 "identity": null,
1057 "name": "label",
1058 "entityType": "columns",
1059 "schema": "public",
1060 "table": "relay_mobile_devices"
1061 },
1062 {
1063 "type": "varchar(16)",
1064 "typeSchema": null,
1065 "notNull": true,
1066 "dimensions": 0,
1067 "default": null,
1068 "generated": null,
1069 "identity": null,
1070 "name": "platform",
1071 "entityType": "columns",
1072 "schema": "public",
1073 "table": "relay_mobile_devices"
1074 },
1075 {
1076 "type": "integer",
1077 "typeSchema": null,
1078 "notNull": false,
1079 "dimensions": 0,
1080 "default": null,
1081 "generated": null,
1082 "identity": null,
1083 "name": "ios_major_version",
1084 "entityType": "columns",
1085 "schema": "public",
1086 "table": "relay_mobile_devices"
1087 },
1088 {
1089 "type": "integer",
1090 "typeSchema": null,
1091 "notNull": false,
1092 "dimensions": 0,
1093 "default": null,
1094 "generated": null,
1095 "identity": null,
1096 "name": "android_api_level",
1097 "entityType": "columns",
1098 "schema": "public",
1099 "table": "relay_mobile_devices"
1100 },
1101 {
1102 "type": "varchar(64)",
1103 "typeSchema": null,
1104 "notNull": false,
1105 "dimensions": 0,
1106 "default": null,
1107 "generated": null,
1108 "identity": null,
1109 "name": "app_version",
1110 "entityType": "columns",
1111 "schema": "public",
1112 "table": "relay_mobile_devices"
1113 },
1114 {
1115 "type": "varchar(255)",
1116 "typeSchema": null,
1117 "notNull": false,
1118 "dimensions": 0,
1119 "default": null,
1120 "generated": null,
1121 "identity": null,
1122 "name": "bundle_id",
1123 "entityType": "columns",
1124 "schema": "public",
1125 "table": "relay_mobile_devices"
1126 },
1127 {
1128 "type": "varchar(16)",
1129 "typeSchema": null,
1130 "notNull": false,
1131 "dimensions": 0,
1132 "default": null,
1133 "generated": null,
1134 "identity": null,
1135 "name": "aps_environment",
1136 "entityType": "columns",
1137 "schema": "public",
1138 "table": "relay_mobile_devices"
1139 },
1140 {
1141 "type": "text",
1142 "typeSchema": null,
1143 "notNull": false,
1144 "dimensions": 0,
1145 "default": null,
1146 "generated": null,
1147 "identity": null,
1148 "name": "push_token",
1149 "entityType": "columns",
1150 "schema": "public",
1151 "table": "relay_mobile_devices"
1152 },
1153 {
1154 "type": "text",
1155 "typeSchema": null,
1156 "notNull": false,
1157 "dimensions": 0,
1158 "default": null,
1159 "generated": null,
1160 "identity": null,
1161 "name": "push_to_start_token",
1162 "entityType": "columns",
1163 "schema": "public",
1164 "table": "relay_mobile_devices"
1165 },
1166 {
1167 "type": "jsonb",
1168 "typeSchema": null,
1169 "notNull": true,
1170 "dimensions": 0,
1171 "default": null,
1172 "generated": null,
1173 "identity": null,
1174 "name": "preferences_json",
1175 "entityType": "columns",
1176 "schema": "public",
1177 "table": "relay_mobile_devices"
1178 },
1179 {
1180 "type": "varchar(64)",
1181 "typeSchema": null,
1182 "notNull": true,
1183 "dimensions": 0,
1184 "default": null,
1185 "generated": null,
1186 "identity": null,
1187 "name": "created_at",
1188 "entityType": "columns",
1189 "schema": "public",
1190 "table": "relay_mobile_devices"
1191 },
1192 {
1193 "type": "varchar(64)",
1194 "typeSchema": null,
1195 "notNull": true,
1196 "dimensions": 0,
1197 "default": null,
1198 "generated": null,
1199 "identity": null,
1200 "name": "updated_at",
1201 "entityType": "columns",
1202 "schema": "public",
1203 "table": "relay_mobile_devices"
1204 },
1205 {
1206 "nameExplicit": true,
1207 "columns": [
1208 {
1209 "value": "updated_at",
1210 "isExpression": false,
1211 "asc": true,
1212 "nullsFirst": false,
1213 "opclass": null
1214 }
1215 ],
1216 "isUnique": false,
1217 "where": null,
1218 "with": "",
1219 "method": "btree",
1220 "concurrently": false,
1221 "name": "idx_relay_agent_activity_rows_updated",
1222 "entityType": "indexes",
1223 "schema": "public",
1224 "table": "relay_agent_activity_rows"
1225 },
1226 {
1227 "nameExplicit": true,
1228 "columns": [
1229 {
1230 "value": "environment_id",
1231 "isExpression": false,
1232 "asc": true,
1233 "nullsFirst": false,
1234 "opclass": null
1235 },
1236 {
1237 "value": "thread_id",
1238 "isExpression": false,
1239 "asc": true,
1240 "nullsFirst": false,
1241 "opclass": null
1242 },
1243 {
1244 "value": "created_at",
1245 "isExpression": false,
1246 "asc": true,
1247 "nullsFirst": false,
1248 "opclass": null
1249 }
1250 ],
1251 "isUnique": false,
1252 "where": null,
1253 "with": "",
1254 "method": "btree",
1255 "concurrently": false,
1256 "name": "idx_relay_delivery_attempts_environment",
1257 "entityType": "indexes",
1258 "schema": "public",
1259 "table": "relay_delivery_attempts"
1260 },
1261 {
1262 "nameExplicit": true,
1263 "columns": [
1264 {
1265 "value": "source_job_id",
1266 "isExpression": false,
1267 "asc": true,
1268 "nullsFirst": false,
1269 "opclass": null
1270 }
1271 ],
1272 "isUnique": true,
1273 "where": null,
1274 "with": "",
1275 "method": "btree",
1276 "concurrently": false,
1277 "name": "idx_relay_delivery_attempts_source_job",
1278 "entityType": "indexes",
1279 "schema": "public",
1280 "table": "relay_delivery_attempts"
1281 },
1282 {
1283 "nameExplicit": true,
1284 "columns": [
1285 {
1286 "value": "expires_at",
1287 "isExpression": false,
1288 "asc": true,
1289 "nullsFirst": false,
1290 "opclass": null
1291 }
1292 ],
1293 "isUnique": false,
1294 "where": null,
1295 "with": "",
1296 "method": "btree",
1297 "concurrently": false,
1298 "name": "idx_relay_dpop_proofs_expires_at",
1299 "entityType": "indexes",
1300 "schema": "public",
1301 "table": "relay_dpop_proofs"
1302 },
1303 {
1304 "nameExplicit": true,
1305 "columns": [
1306 {
1307 "value": "credential_hash",
1308 "isExpression": false,
1309 "asc": true,
1310 "nullsFirst": false,
1311 "opclass": null
1312 }
1313 ],
1314 "isUnique": true,
1315 "where": null,
1316 "with": "",
1317 "method": "btree",
1318 "concurrently": false,
1319 "name": "idx_relay_environment_credentials_hash",
1320 "entityType": "indexes",
1321 "schema": "public",
1322 "table": "relay_environment_credentials"
1323 },
1324 {
1325 "nameExplicit": true,
1326 "columns": [
1327 {
1328 "value": "environment_id",
1329 "isExpression": false,
1330 "asc": true,
1331 "nullsFirst": false,
1332 "opclass": null
1333 },
1334 {
1335 "value": "revoked_at",
1336 "isExpression": false,
1337 "asc": true,
1338 "nullsFirst": false,
1339 "opclass": null
1340 }
1341 ],
1342 "isUnique": false,
1343 "where": null,
1344 "with": "",
1345 "method": "btree",
1346 "concurrently": false,
1347 "name": "idx_relay_environment_credentials_environment",
1348 "entityType": "indexes",
1349 "schema": "public",
1350 "table": "relay_environment_credentials"
1351 },
1352 {
1353 "nameExplicit": true,
1354 "columns": [
1355 {
1356 "value": "environment_id",
1357 "isExpression": false,
1358 "asc": true,
1359 "nullsFirst": false,
1360 "opclass": null
1361 },
1362 {
1363 "value": "environment_public_key",
1364 "isExpression": false,
1365 "asc": true,
1366 "nullsFirst": false,
1367 "opclass": null
1368 },
1369 {
1370 "value": "revoked_at",
1371 "isExpression": false,
1372 "asc": true,
1373 "nullsFirst": false,
1374 "opclass": null
1375 }
1376 ],
1377 "isUnique": false,
1378 "where": null,
1379 "with": "",
1380 "method": "btree",
1381 "concurrently": false,
1382 "name": "idx_relay_environment_credentials_environment_key",
1383 "entityType": "indexes",
1384 "schema": "public",
1385 "table": "relay_environment_credentials"
1386 },
1387 {
1388 "nameExplicit": true,
1389 "columns": [
1390 {
1391 "value": "environment_id",
1392 "isExpression": false,
1393 "asc": true,
1394 "nullsFirst": false,
1395 "opclass": null
1396 },
1397 {
1398 "value": "revoked_at",
1399 "isExpression": false,
1400 "asc": true,
1401 "nullsFirst": false,
1402 "opclass": null
1403 }
1404 ],
1405 "isUnique": false,
1406 "where": null,
1407 "with": "",
1408 "method": "btree",
1409 "concurrently": false,
1410 "name": "idx_relay_environment_links_environment",
1411 "entityType": "indexes",
1412 "schema": "public",
1413 "table": "relay_environment_links"
1414 },
1415 {
1416 "nameExplicit": true,
1417 "columns": [
1418 {
1419 "value": "activity_push_token",
1420 "isExpression": false,
1421 "asc": true,
1422 "nullsFirst": false,
1423 "opclass": null
1424 }
1425 ],
1426 "isUnique": true,
1427 "where": null,
1428 "with": "",
1429 "method": "btree",
1430 "concurrently": false,
1431 "name": "idx_relay_live_activities_activity_push_token",
1432 "entityType": "indexes",
1433 "schema": "public",
1434 "table": "relay_live_activities"
1435 },
1436 {
1437 "nameExplicit": true,
1438 "columns": [
1439 {
1440 "value": "hostname",
1441 "isExpression": false,
1442 "asc": true,
1443 "nullsFirst": false,
1444 "opclass": null
1445 }
1446 ],
1447 "isUnique": true,
1448 "where": null,
1449 "with": "",
1450 "method": "btree",
1451 "concurrently": false,
1452 "name": "idx_relay_managed_endpoint_allocations_hostname",
1453 "entityType": "indexes",
1454 "schema": "public",
1455 "table": "relay_managed_endpoint_allocations"
1456 },
1457 {
1458 "nameExplicit": true,
1459 "columns": [
1460 {
1461 "value": "tunnel_name",
1462 "isExpression": false,
1463 "asc": true,
1464 "nullsFirst": false,
1465 "opclass": null
1466 }
1467 ],
1468 "isUnique": true,
1469 "where": null,
1470 "with": "",
1471 "method": "btree",
1472 "concurrently": false,
1473 "name": "idx_relay_managed_endpoint_allocations_tunnel_name",
1474 "entityType": "indexes",
1475 "schema": "public",
1476 "table": "relay_managed_endpoint_allocations"
1477 },
1478 {
1479 "nameExplicit": true,
1480 "columns": [
1481 {
1482 "value": "push_token",
1483 "isExpression": false,
1484 "asc": true,
1485 "nullsFirst": false,
1486 "opclass": null
1487 }
1488 ],
1489 "isUnique": true,
1490 "where": null,
1491 "with": "",
1492 "method": "btree",
1493 "concurrently": false,
1494 "name": "idx_relay_mobile_devices_push_token",
1495 "entityType": "indexes",
1496 "schema": "public",
1497 "table": "relay_mobile_devices"
1498 },
1499 {
1500 "nameExplicit": true,
1501 "columns": [
1502 {
1503 "value": "push_to_start_token",
1504 "isExpression": false,
1505 "asc": true,
1506 "nullsFirst": false,
1507 "opclass": null
1508 }
1509 ],
1510 "isUnique": true,
1511 "where": null,
1512 "with": "",
1513 "method": "btree",
1514 "concurrently": false,
1515 "name": "idx_relay_mobile_devices_push_to_start_token",
1516 "entityType": "indexes",
1517 "schema": "public",
1518 "table": "relay_mobile_devices"
1519 },
1520 {
1521 "columns": ["environment_id", "environment_public_key", "thread_id"],
1522 "nameExplicit": false,
1523 "name": "relay_agent_activity_rows_pkey",
1524 "entityType": "pks",
1525 "schema": "public",
1526 "table": "relay_agent_activity_rows"
1527 },
1528 {
1529 "columns": ["thumbprint", "jti"],
1530 "nameExplicit": false,
1531 "name": "relay_dpop_proofs_pkey",
1532 "entityType": "pks",
1533 "schema": "public",
1534 "table": "relay_dpop_proofs"
1535 },
1536 {
1537 "columns": ["user_id", "environment_id"],
1538 "nameExplicit": false,
1539 "name": "relay_environment_links_pkey",
1540 "entityType": "pks",
1541 "schema": "public",
1542 "table": "relay_environment_links"
1543 },
1544 {
1545 "columns": ["user_id", "device_id"],
1546 "nameExplicit": false,
1547 "name": "relay_live_activities_pkey",
1548 "entityType": "pks",
1549 "schema": "public",
1550 "table": "relay_live_activities"
1551 },
1552 {
1553 "columns": ["user_id", "environment_id"],
1554 "nameExplicit": false,
1555 "name": "relay_managed_endpoint_allocations_pkey",
1556 "entityType": "pks",
1557 "schema": "public",
1558 "table": "relay_managed_endpoint_allocations"
1559 },
1560 {
1561 "columns": ["user_id", "device_id"],
1562 "nameExplicit": false,
1563 "name": "relay_mobile_devices_pkey",
1564 "entityType": "pks",
1565 "schema": "public",
1566 "table": "relay_mobile_devices"
1567 },
1568 {
1569 "columns": ["id"],
1570 "nameExplicit": false,
1571 "name": "relay_delivery_attempts_pkey",
1572 "schema": "public",
1573 "table": "relay_delivery_attempts",
1574 "entityType": "pks"
1575 },
1576 {
1577 "columns": ["credential_id"],
1578 "nameExplicit": false,
1579 "name": "relay_environment_credentials_pkey",
1580 "schema": "public",
1581 "table": "relay_environment_credentials",
1582 "entityType": "pks"
1583 },
1584 {
1585 "columns": ["user_id"],
1586 "nameExplicit": false,
1587 "name": "relay_managed_tunnel_limits_pkey",
1588 "schema": "public",
1589 "table": "relay_managed_tunnel_limits",
1590 "entityType": "pks"
1591 }
1592 ],
1593 "renames": []
1594}
infra/relay/src/environments/EnvironmentConnector.test.ts+33
......@@ -191,6 +191,7 @@ function makeAllocations(
191191 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
192192 updatedAt: "2026-05-25T00:00:00.000Z",
193193 generation: 1,
194 tunnelReleasedAt: null,
194195 },
195196): ManagedEndpointAllocations.ManagedEndpointAllocations["Service"] {
196197 return {
......@@ -480,6 +481,7 @@ describe("EnvironmentConnector", () => {
480481 origin: null,
481482 updatedAt: "2026-05-25T00:00:00.000Z",
482483 generation: 1,
484 tunnelReleasedAt: null,
483485 }),
484486 }),
485487 ),
......@@ -550,9 +552,40 @@ describe("EnvironmentConnector", () => {
550552 error: "Managed endpoint health request failed: Environment is unavailable.",
551553 traceId: expect.any(String),
552554 });
555 expect(result).not.toHaveProperty("offlineReason");
553556 }).pipe(Effect.provide(layerConnectorTest(execute)));
554557 });
555558
559 it.effect("reports a released tunnel as the reason an environment is offline", () => {
560 const execute = (request: HttpClientRequest.HttpClientRequest) =>
561 Effect.succeed(
562 HttpClientResponse.fromWeb(request, new Response("tunnel not found", { status: 530 })),
563 );
564 const allocations = makeAllocations({
565 userId: "user_123",
566 environmentId: "env-connector-test",
567 hostname: "env.example.test",
568 tunnelId: "tunnel-id",
569 tunnelName: "tunnel-name",
570 dnsRecordId: "dns-record-id",
571 readyAt: "2026-05-25T00:00:00.000Z",
572 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
573 updatedAt: "2026-05-25T00:00:00.000Z",
574 generation: 2,
575 tunnelReleasedAt: "2026-05-26T00:00:00.000Z",
576 });
577
578 return Effect.gen(function* () {
579 const connector = yield* EnvironmentConnector.EnvironmentConnector;
580 const result = yield* connector.status({
581 userId: "user_123",
582 environmentId: "env-connector-test",
583 });
584
585 expect(result).toMatchObject({ status: "offline", offlineReason: "tunnel_released" });
586 }).pipe(Effect.provide(layerConnectorTest(execute, { allocations })));
587 });
588
556589 it.effect("rejects health responses with a mismatched top-level environment id", () => {
557590 const execute = (request: HttpClientRequest.HttpClientRequest) =>
558591 Effect.sync(() => {
infra/relay/src/environments/EnvironmentConnector.ts+12
......@@ -163,6 +163,16 @@ const isEnvironmentHealthError = Schema.is(
163163 ]),
164164);
165165
166/**
167 * Cleanup deleted this environment's tunnel and nothing has recorded a new
168 * one, so the host is unreachable until it updates and requests recovery.
169 */
170function offlineReasonFor(
171 allocation: ManagedEndpointAllocations.ManagedEndpointAllocation | null,
172): { readonly offlineReason?: "tunnel_released" } {
173 return allocation?.tunnelReleasedAt ? { offlineReason: "tunnel_released" } : {};
174}
175
166176function environmentHealthRequestFailureMessage(cause: unknown): string {
167177 return isEnvironmentHealthError(cause)
168178 ? `Managed endpoint health request failed: ${cause.message}`
......@@ -510,6 +520,7 @@ const make = Effect.gen(function* () {
510520 status: "offline" as const,
511521 checkedAt,
512522 error: "Managed endpoint health request timed out.",
523 ...offlineReasonFor(allocation),
513524 traceId,
514525 };
515526 }
......@@ -532,6 +543,7 @@ const make = Effect.gen(function* () {
532543 status: "offline" as const,
533544 checkedAt,
534545 error: environmentHealthRequestFailureMessage(responseOption.value.cause),
546 ...offlineReasonFor(allocation),
535547 traceId,
536548 };
537549 }
infra/relay/src/environments/ManagedEndpointAllocations.test.ts+36
......@@ -19,6 +19,7 @@ describe("ManagedEndpointAllocations", () => {
1919 readonly readyAt: unknown;
2020 readonly recoveryEnabledAt: unknown;
2121 readonly recoveryEnvironmentPublicKey: unknown;
22 readonly tunnelReleasedAt: unknown;
2223 }
2324 | undefined;
2425 const fakeDb = {
......@@ -30,6 +31,7 @@ describe("ManagedEndpointAllocations", () => {
3031 readonly readyAt: unknown;
3132 readonly recoveryEnabledAt: unknown;
3233 readonly recoveryEnvironmentPublicKey: unknown;
34 readonly tunnelReleasedAt: unknown;
3335 }) => {
3436 updated = values;
3537 return {
......@@ -53,6 +55,8 @@ describe("ManagedEndpointAllocations", () => {
5355 }),
5456 ).toBe(8);
5557 expect(updated?.tunnelId).toBe("replacement-tunnel");
58 // Any recorded tunnel ends a released state, so status stops reporting it.
59 expect(updated?.tunnelReleasedAt).toBeNull();
5660 const query = new PgDialect().sqlToQuery(updated?.readyAt as never);
5761 expect(query.sql).toBe(
5862 'case when "relay_managed_endpoint_allocations"."tunnel_id" = $1 then "relay_managed_endpoint_allocations"."ready_at" else null end',
......@@ -250,6 +254,38 @@ describe("ManagedEndpointAllocations", () => {
250254 }).pipe(Effect.provide(layerWithDb(fakeDb)));
251255 });
252256
257 it.effect.each([
258 { markReleased: true, released: true },
259 { markReleased: undefined, released: false },
260 ] as const)(
261 "marks the tunnel released only for the claim that deletes it ($markReleased)",
262 ({ markReleased, released }) => {
263 let updated: Record<string, unknown> | undefined;
264 const fakeDb = {
265 update: () => ({
266 set: (values: Record<string, unknown>) => {
267 updated = values;
268 return {
269 where: () => ({ returning: () => Effect.succeed([{ generation: 4 }]) }),
270 };
271 },
272 }),
273 } as unknown as RelayDb.RelayDb["Service"];
274
275 return Effect.gen(function* () {
276 const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations;
277 yield* allocations.claimRelease({
278 userId: "user-1",
279 environmentId: "environment-1",
280 tunnelId: "tunnel-1",
281 generation: 3,
282 ...(markReleased === undefined ? {} : { markReleased }),
283 });
284 expect(typeof updated?.tunnelReleasedAt === "string").toBe(released);
285 }).pipe(Effect.provide(layerWithDb(fakeDb)));
286 },
287 );
288
253289 it.effect("returns a claim generation only when deprovision wins the allocation CAS", () => {
254290 const fakeDb = {
255291 update: (table: unknown) => {
infra/relay/src/environments/ManagedEndpointAllocations.ts+9-1
......@@ -24,6 +24,8 @@ export interface ManagedEndpointAllocation {
2424 readonly origin: RelayManagedEndpointOrigin | null;
2525 readonly updatedAt: string;
2626 readonly generation: number;
27 /** When cleanup deleted the recorded tunnel; null once a tunnel is recorded again. */
28 readonly tunnelReleasedAt: string | null;
2729}
2830
2931export interface ManagedEndpointTunnelAllocation extends ManagedEndpointAllocation {
......@@ -111,6 +113,8 @@ interface MarkManagedEndpointReadyInput extends ManagedEndpointAllocationKey {
111113interface ClaimManagedEndpointReleaseInput extends ManagedEndpointAllocationKey {
112114 readonly tunnelId: string;
113115 readonly generation: number;
116 /** Record that the tunnel is being deleted; set only by the claim that deletes it. */
117 readonly markReleased?: boolean;
114118}
115119
116120interface EnableManagedEndpointRecoveryInput extends ManagedEndpointAllocationKey {
......@@ -202,6 +206,7 @@ const allocationSelection = {
202206 origin: relayManagedEndpointAllocations.origin,
203207 updatedAt: relayManagedEndpointAllocations.updatedAt,
204208 generation: relayManagedEndpointAllocations.generation,
209 tunnelReleasedAt: relayManagedEndpointAllocations.tunnelReleasedAt,
205210};
206211
207212const whereAllocation = (input: ManagedEndpointAllocationKey) =>
......@@ -326,6 +331,7 @@ export const make = Effect.gen(function* () {
326331 // again before the reaper may treat it as recoverable.
327332 recoveryEnabledAt: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnabledAt} else null end`,
328333 recoveryEnvironmentPublicKey: sql`case when ${relayManagedEndpointAllocations.tunnelId} = ${input.tunnelId} then ${relayManagedEndpointAllocations.recoveryEnvironmentPublicKey} else null end`,
334 tunnelReleasedAt: null,
329335 updatedAt: DateTime.formatIso(yield* DateTime.now),
330336 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
331337 })
......@@ -534,11 +540,13 @@ export const make = Effect.gen(function* () {
534540 claimRelease: Effect.fn("relay.managed_endpoint_allocations.claim_release")(function* (
535541 input: ClaimManagedEndpointReleaseInput,
536542 ) {
543 const now = DateTime.formatIso(yield* DateTime.now);
537544 const claimed = yield* db
538545 .update(relayManagedEndpointAllocations)
539546 .set({
540 updatedAt: DateTime.formatIso(yield* DateTime.now),
547 updatedAt: now,
541548 generation: sql`${relayManagedEndpointAllocations.generation} + 1`,
549 ...(input.markReleased === true ? { tunnelReleasedAt: now } : {}),
542550 })
543551 .where(
544552 and(
infra/relay/src/environments/ManagedEndpointProvider.test.ts+80
......@@ -221,6 +221,7 @@ function makeAllocations(calls: AllocationCall[] = []) {
221221 origin: null,
222222 updatedAt: `generation-${++generation}`,
223223 generation: 0,
224 tunnelReleasedAt: null,
224225 };
225226 allocations.set(allocationKey(input), allocation);
226227 return allocation;
......@@ -968,6 +969,38 @@ describe("ManagedEndpointProvider", () => {
968969 "updateRecord",
969970 ]);
970971 expect(allocationCalls.map((call) => call.operation)).not.toContain("remove");
972 // An ordinary release, such as a host shutting down, must not tell the
973 // user to update: that host gets a new tunnel when it starts again.
974 const claims = allocationCalls.filter((call) => call.operation === "claimRelease");
975 expect(claims.map((call) => (call.input as { markReleased?: boolean }).markReleased)).toEqual(
976 [undefined, undefined],
977 );
978 }).pipe(Effect.provide(layer));
979 });
980
981 it.effect("marks the release on the claim that deletes the tunnel when asked", () => {
982 const allocationCalls: AllocationCall[] = [];
983 const layer = layerProvider(
984 makePersistentTunnelClient(),
985 makeDnsClient(),
986 makeAllocations(allocationCalls),
987 );
988
989 return Effect.gen(function* () {
990 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
991 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
992 yield* provider.provision({
993 ...key,
994 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
995 });
996 expect(yield* provider.release({ ...key, markReleased: true })).toBe(true);
997
998 // The first claim only reserves the release; the second, taken with the
999 // row locked right before the delete, is the one that records it.
1000 const claims = allocationCalls.filter((call) => call.operation === "claimRelease");
1001 expect(claims.map((call) => (call.input as { markReleased?: boolean }).markReleased)).toEqual(
1002 [undefined, true],
1003 );
9711004 }).pipe(Effect.provide(layer));
9721005 });
9731006
......@@ -1502,6 +1535,53 @@ describe("ManagedEndpointProvider", () => {
15021535 }).pipe(Effect.provide(layer));
15031536 });
15041537
1538 it.effect.each([
1539 { name: "the tunnel is gone", tunnelRemoved: true, released: true },
1540 { name: "the tunnel still exists", tunnelRemoved: false, released: false },
1541 ] as const)(
1542 "resolves a delete whose response was lost by checking whether $name",
1543 ({ tunnelRemoved, released }) => {
1544 const persistent = makePersistentTunnelClient();
1545 const lostResponse = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
1546 operation: "delete",
1547 tunnelId: "tunnel-id",
1548 cause: { _tag: "TimeoutError" },
1549 });
1550 // Cloudflare may or may not have applied the delete before the
1551 // response was lost; the client only sees the timeout.
1552 const tunnelClient = ManagedEndpointProvider.ManagedEndpointTunnelClient.of({
1553 ...persistent,
1554 delete: (tunnelId) =>
1555 (tunnelRemoved ? persistent.delete(tunnelId) : Effect.void).pipe(
1556 Effect.andThen(Effect.fail(lostResponse)),
1557 ),
1558 });
1559 const allocationCalls: AllocationCall[] = [];
1560 const layer = layerProvider(tunnelClient, makeDnsClient(), makeAllocations(allocationCalls));
1561
1562 return Effect.gen(function* () {
1563 const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider;
1564 const key = { userId: "user_ABC", environmentId: "env_ABC" } as const;
1565 yield* provider.provision({
1566 ...key,
1567 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
1568 });
1569 const result = yield* Effect.result(provider.release({ ...key, markReleased: true }));
1570
1571 if (released) {
1572 // Succeeding commits the marked claim, so status can explain it.
1573 expect(result).toMatchObject({ _tag: "Success", success: true });
1574 } else {
1575 // Failing rolls the claim back; the tunnel is still there to retry.
1576 expect(result).toMatchObject({
1577 _tag: "Failure",
1578 failure: { stage: "delete-tunnel", cause: lostResponse },
1579 });
1580 }
1581 }).pipe(Effect.provide(layer));
1582 },
1583 );
1584
15051585 it.effect("surfaces non-not-found tunnel deletion failures when releasing", () => {
15061586 const failure = new ManagedEndpointProvider.ManagedEndpointTunnelClientError({
15071587 operation: "delete",
infra/relay/src/environments/ManagedEndpointProvider.ts+28
......@@ -193,6 +193,11 @@ export class ManagedEndpointProvider extends Context.Service<
193193 readonly expectedTunnelId?: string;
194194 readonly expectedInactiveBefore?: string;
195195 readonly expectedStatus?: "inactive" | "down";
196 /**
197 * Record that cleanup removed a legacy host's tunnel, so status tells the
198 * user to update. Other releases leave hosts that recover on their own.
199 */
200 readonly markReleased?: boolean;
196201 }) => Effect.Effect<boolean, ManagedEndpointDeprovisioningFailed>;
197202 }
198203>()("t3code-relay/environments/ManagedEndpointProvider") {}
......@@ -808,6 +813,19 @@ export const make = Effect.gen(function* () {
808813 if (claimedGeneration === null) {
809814 return false;
810815 }
816 // After a failed delete: succeed if the tunnel is gone, since the delete
817 // then took effect and only its response was lost; otherwise keep the
818 // delete's error.
819 const confirmTunnelGone = (
820 failure: ManagedEndpointDeprovisioningFailed,
821 ): Effect.Effect<void, ManagedEndpointDeprovisioningFailed> =>
822 tunnels.get(tunnelId).pipe(
823 Effect.andThen(Effect.fail(failure)),
824 Effect.catchTags({
825 ManagedEndpointTunnelClientError: (lookupFailure) =>
826 isManagedEndpointNotFound(lookupFailure.cause) ? Effect.void : Effect.fail(failure),
827 }),
828 );
811829 const deleteTunnel = ignoreNotFound(tunnels.delete(tunnelId)).pipe(
812830 Effect.mapError(
813831 (cause) =>
......@@ -877,6 +895,7 @@ export const make = Effect.gen(function* () {
877895 environmentId: input.environmentId,
878896 tunnelId,
879897 generation: claimedGeneration,
898 ...(input.markReleased === true ? { markReleased: true } : {}),
880899 })
881900 .pipe(
882901 Effect.mapError(
......@@ -895,12 +914,21 @@ export const make = Effect.gen(function* () {
895914 // A connector still attached means the tunnel is not released. That
896915 // is the same answer as losing the claim: the caller keeps its config,
897916 // and the reaper deletes the tunnel once it has been down long enough.
917 // A delete whose response is lost (a timeout) may still have
918 // removed the tunnel. Ask Cloudflare before rolling back: if the
919 // tunnel is gone, the delete happened and the claim, including any
920 // released marker, must commit, since no later sweep can find
921 // this tunnel again to retry.
898922 return yield* deleteTunnel.pipe(
899923 Effect.as(true),
900924 Effect.catchIf(
901925 (error) => isManagedEndpointTunnelInUse(error.cause),
902926 () => Effect.succeed(false),
903927 ),
928 Effect.catchTags({
929 ManagedEndpointDeprovisioningFailed: (failure) =>
930 confirmTunnelGone(failure).pipe(Effect.as(true)),
931 }),
904932 );
905933 }),
906934 )
infra/relay/src/environments/ManagedEndpointReaper.test.ts+5
......@@ -55,6 +55,7 @@ function allocation(input: {
5555 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
5656 updatedAt: "2026-08-25T11:00:00.000Z",
5757 generation: 1,
58 tunnelReleasedAt: null,
5859 recoveryEnabled: input.recoveryEnabled,
5960 };
6061}
......@@ -296,6 +297,8 @@ describe("ManagedEndpointReaper", () => {
296297 failed: 0,
297298 });
298299 expect(state.deleted).toEqual(["down-1", "inactive-1"]);
300 // A host that registered recovery gets a new tunnel on its own.
301 expect(state.releases.some((release) => release.markReleased === true)).toBe(false);
299302 expect(state.releases.map((request) => request.expectedTunnelId)).toEqual([
300303 "down-1",
301304 "inactive-1",
......@@ -582,6 +585,8 @@ describe("ManagedEndpointReaper", () => {
582585 deleted: 2,
583586 });
584587 expect([...state.deleted].sort()).toEqual(["legacy-never", "legacy-old"]);
588 // A legacy host needs an update to recover, so its user is told why.
589 expect(state.releases.every((release) => release.markReleased === true)).toBe(true);
585590 // The release re-checks each tunnel against the 7-day cutoff, not
586591 // the 5-minute cutoff used for recoverable tunnels.
587592 for (const release of state.releases) {
infra/relay/src/environments/ManagedEndpointReaper.ts+2
......@@ -386,6 +386,8 @@ export const make = Effect.gen(function* () {
386386 expectedTunnelId: candidate.tunnel.id,
387387 expectedInactiveBefore: candidate.inactiveBefore,
388388 expectedStatus: candidate.status,
389 // Only a legacy host needs an update to recover; tell its user so.
390 ...(candidate.legacy ? { markReleased: true } : {}),
389391 })
390392 .pipe(Effect.result);
391393 if (result._tag === "Failure") {
infra/relay/src/hooks/HeldHooks.test.ts+1
......@@ -31,6 +31,7 @@ const allocationFor = (
3131 tunnelName: `t3coderelay-managedendpoint-dev-${key}`,
3232 dnsRecordId: "dns-record-id",
3333 readyAt: ready ? "2026-05-25T00:00:00.000Z" : null,
34 tunnelReleasedAt: null,
3435 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
3536 updatedAt: "2026-05-25T00:00:00.000Z",
3637 generation: 1,
infra/relay/src/hooks/HookForwarder.test.ts+1
......@@ -66,6 +66,7 @@ const readyAllocation: ManagedEndpointAllocations.ManagedEndpointAllocation = {
6666 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
6767 dnsRecordId: "dns-record-id",
6868 readyAt: "2026-05-25T00:00:00.000Z",
69 tunnelReleasedAt: null,
6970 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
7071 updatedAt: "2026-05-25T00:00:00.000Z",
7172 generation: 1,
infra/relay/src/http/Api.test.ts+4
......@@ -822,6 +822,7 @@ describe("relay managed tunnel recovery", () => {
822822 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
823823 updatedAt: "replacement-generation",
824824 generation: 3,
825 tunnelReleasedAt: null,
825826 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
826827
827828 return Effect.gen(function* () {
......@@ -924,6 +925,7 @@ describe("relay environment unlink", () => {
924925 tunnelName: `t3coderelay-managedendpoint-dev-${endpointKey}`,
925926 dnsRecordId: "dns-1",
926927 readyAt: "2026-07-28T00:00:00.000Z",
928 tunnelReleasedAt: null,
927929 origin: null,
928930 updatedAt: "2026-07-28T00:00:00.000Z",
929931 generation: 1,
......@@ -962,6 +964,7 @@ describe("relay environment unlink", () => {
962964 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
963965 updatedAt: "generation-before-unlink",
964966 generation: 1,
967 tunnelReleasedAt: null,
965968 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
966969
967970 return Effect.gen(function* () {
......@@ -1108,6 +1111,7 @@ describe("relay environment unlink", () => {
11081111 origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 },
11091112 updatedAt: "original-generation",
11101113 generation: 1,
1114 tunnelReleasedAt: null,
11111115 } satisfies ManagedEndpointProvider.ManagedEndpointDeprovisionTarget;
11121116
11131117 return Effect.gen(function* () {
infra/relay/src/persistence/schema.ts+2
......@@ -99,6 +99,8 @@ export const relayManagedEndpointAllocations = pgTable(
9999 readyAt: varchar("ready_at", { length: 64 }),
100100 recoveryEnabledAt: varchar("recovery_enabled_at", { length: 64 }),
101101 recoveryEnvironmentPublicKey: text("recovery_environment_public_key"),
102 // Set when cleanup deletes the recorded tunnel; cleared when a tunnel is recorded again.
103 tunnelReleasedAt: varchar("tunnel_released_at", { length: 64 }),
102104 origin: jsonb("origin").$type<RelayManagedEndpointOrigin>(),
103105 generation: integer("generation").notNull().default(0),
104106 createdAt: varchar("created_at", { length: 64 }).notNull(),
packages/client-runtime/src/relay/errorPresentation.ts+19-1
......@@ -1,5 +1,5 @@
11import type { DpopFailureReason } from "@t3tools/contracts";
2import type { RelayProtectedError } from "@t3tools/contracts/relay";
2import type { RelayEnvironmentStatusResponse, RelayProtectedError } from "@t3tools/contracts/relay";
33
44export const DPOP_CLOCK_HINT =
55 "Hint: Check that automatic date and time is enabled on both devices, then try again.";
......@@ -64,3 +64,21 @@ export function relayProtectedErrorMessage(error: RelayProtectedError): string {
6464 return `Relay encountered an internal error (${error.reason}).`;
6565 }
6666}
67
68// A host with a current build gets a new tunnel on its own within minutes of
69// coming back, which clears this reason. While it is still reported, the host
70// is either still off or running a build too old to do that.
71export const RELAY_TUNNEL_RELEASED_MESSAGE =
72 "Offline for a while, so its T3 Connect tunnel was removed. Start T3 Code on that computer and update it to the latest version to reconnect.";
73
74/** User-facing text for an offline status, or null when the relay gave no known reason. */
75export function relayOfflineReasonMessage(
76 status: Pick<RelayEnvironmentStatusResponse, "offlineReason">,
77): string | null {
78 switch (status.offlineReason) {
79 case "tunnel_released":
80 return RELAY_TUNNEL_RELEASED_MESSAGE;
81 case undefined:
82 return null;
83 }
84}
packages/contracts/src/relay.ts+9
......@@ -823,6 +823,14 @@ export type RelayEnvironmentConnectResponse = typeof RelayEnvironmentConnectResp
823823export const RelayEnvironmentStatusValue = Schema.Literals(["online", "offline"]);
824824export type RelayEnvironmentStatusValue = typeof RelayEnvironmentStatusValue.Type;
825825
826/**
827 * Why an environment is offline, when the relay knows more than "the host
828 * did not answer". `tunnel_released`: the relay deleted the environment's
829 * idle tunnel, and the host needs a current T3 Code build to get a new one.
830 */
831const RelayEnvironmentOfflineReason = Schema.Literals(["tunnel_released"]);
832type RelayEnvironmentOfflineReason = typeof RelayEnvironmentOfflineReason.Type;
833
826834export const RelayEnvironmentStatusResponse = Schema.Struct({
827835 environmentId: EnvironmentId,
828836 endpoint: RelayManagedEndpoint,
......@@ -830,6 +838,7 @@ export const RelayEnvironmentStatusResponse = Schema.Struct({
830838 checkedAt: TrimmedNonEmptyString,
831839 descriptor: Schema.optional(ExecutionEnvironmentDescriptor),
832840 error: Schema.optional(TrimmedNonEmptyString),
841 offlineReason: Schema.optional(RelayEnvironmentOfflineReason),
833842 traceId: Schema.optional(TrimmedNonEmptyString),
834843});
835844export type RelayEnvironmentStatusResponse = typeof RelayEnvironmentStatusResponse.Type;