@treeseed/sdk 0.12.54 → 0.12.56

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.
@@ -12,7 +12,11 @@ import {
12
12
  normalizeRailwayEnvironmentName,
13
13
  resolveRailwayWorkspaceContext
14
14
  } from "./railway-api.js";
15
- import { configuredRailwayServices, findStaleTreeseedOperationsRunnerResources } from "./railway-deploy.js";
15
+ import {
16
+ configuredRailwayServices,
17
+ findStaleTreeseedOperationsRunnerResources,
18
+ isTreeseedOperationsRunnerResourceName
19
+ } from "./railway-deploy.js";
16
20
  import { discoverTreeseedApplications } from "../../hosting/apps.js";
17
21
  import {
18
22
  collectTreeseedHostedServiceChecks
@@ -235,6 +239,8 @@ async function collectRailwayObservations(options) {
235
239
  const workspace = await resolveRailwayWorkspaceContext({ env: options.env, fetchImpl: options.fetchImpl });
236
240
  const projects = await listRailwayProjects({ workspaceId: workspace.id, env: options.env, fetchImpl: options.fetchImpl });
237
241
  const configuredServices = configuredRailwayServices(options.tenantRoot, options.target, options.env).filter((entry) => serviceMatchesAppSelection(entry, options.tenantRoot, options.appId, applications)).filter((entry) => serviceIsSelected(selectedServiceKeys, entry.key));
242
+ const siblingTarget = options.target === "staging" ? "prod" : options.target === "prod" ? "staging" : null;
243
+ const configuredSiblingServices = siblingTarget ? configuredRailwayServices(options.tenantRoot, siblingTarget, options.env, { identityOnly: true }).filter((entry) => entry.enabled !== false).filter((entry) => entry.key === "operationsRunner") : [];
238
244
  if (selectedServiceKeys.size === 0 || selectedServiceKeys.has("api") || selectedServiceKeys.has("operationsRunner")) {
239
245
  for (const descriptor of treeseedDatabaseDescriptors(options.tenantRoot, options)) {
240
246
  await verifyRailwayPostgresTopology({ descriptor, configuredServices, projects, options, issues });
@@ -278,7 +284,10 @@ async function collectRailwayObservations(options) {
278
284
  const runnerScope = `${project.id}:${environment.id}`;
279
285
  if (service.key === "operationsRunner" && !inspectedRunnerScopes.has(runnerScope)) {
280
286
  inspectedRunnerScopes.add(runnerScope);
281
- const desiredRunnerNames = new Set(configuredServices.filter((entry) => entry.key === "operationsRunner").filter((entry) => entry.projectId ? entry.projectId === project.id : entry.projectName === project.name).filter((entry) => normalizeRailwayEnvironmentName(entry.railwayEnvironment) === normalizeRailwayEnvironmentName(environment.name)).map((entry) => entry.serviceName).filter(Boolean));
287
+ const desiredRunnerNames = /* @__PURE__ */ new Set([
288
+ ...configuredServices.filter((entry) => entry.key === "operationsRunner").filter((entry) => entry.projectId ? entry.projectId === project.id : entry.projectName === project.name).filter((entry) => normalizeRailwayEnvironmentName(entry.railwayEnvironment) === normalizeRailwayEnvironmentName(environment.name)).map((entry) => entry.serviceName).filter(Boolean),
289
+ ...configuredSiblingServices.filter((entry) => entry.projectId ? entry.projectId === project.id : entry.projectName === project.name).map((entry) => entry.serviceName).filter((name) => Boolean(name) && isTreeseedOperationsRunnerResourceName(name))
290
+ ]);
282
291
  const desiredRunnerServiceIds = new Set(services.filter((entry) => desiredRunnerNames.has(entry.name)).map((entry) => entry.id));
283
292
  for (const staleService of findStaleTreeseedOperationsRunnerResources(services, desiredRunnerNames)) {
284
293
  issues.push(`${staleService.name}: stale operations runner Railway service remains in project ${project.name}.`);
@@ -59,7 +59,7 @@ export declare function waitForRailwayManagedDeploymentsSettled(tenantRoot: any,
59
59
  };
60
60
  message: string;
61
61
  }>;
62
- export declare function configuredRailwayServices(tenantRoot: any, scope: any, envOverlay?: {}): unknown[];
62
+ export declare function configuredRailwayServices(tenantRoot: any, scope: any, envOverlay?: {}, options?: {}): unknown[];
63
63
  export declare function configuredRailwayScheduledJobs(tenantRoot: any, scope: any, { phase }?: {
64
64
  phase?: string | undefined;
65
65
  }): any[];
@@ -641,8 +641,9 @@ query TreeseedRailwayDeploymentStatus($projectId: String!) {
641
641
  });
642
642
  return payload.data?.project ?? null;
643
643
  }
644
- function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, application = null, machineConfigRoot = tenantRoot, envOverlay = {}) {
644
+ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, application = null, machineConfigRoot = tenantRoot, envOverlay = {}, options = {}) {
645
645
  const normalizedScope = normalizeScope(scope);
646
+ const identityOnly = options.identityOnly === true;
646
647
  const imageRefKeys = [
647
648
  "TREESEED_API_IMAGE_REF",
648
649
  "TREESEED_OPERATIONS_RUNNER_IMAGE_REF",
@@ -702,7 +703,13 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
702
703
  const configuredImageRefEnv = service.railway?.imageRefEnv ?? railwayImageRefEnvForService(serviceKey);
703
704
  const canUseImageRefEnv = normalizedScope === "prod" || serviceKey === "capacityProviderManager" || serviceKey === "capacityProviderRunner";
704
705
  const imageRef = service.railway?.imageRef ?? (canUseImageRefEnv && configuredImageRefEnv ? envValue(configuredImageRefEnv, imageRefEnv) || null : null) ?? defaultRailwayImageRef(serviceKey, normalizedScope, imageRefEnv);
705
- const sourcePolicy = resolveRailwayServiceSourcePolicy({
706
+ const sourcePolicy = identityOnly ? {
707
+ sourceMode: normalizedScope === "prod" ? "image" : "git",
708
+ sourceRepo: null,
709
+ sourceBranch: null,
710
+ sourceCommit: null,
711
+ sourceRootDirectory: null
712
+ } : resolveRailwayServiceSourcePolicy({
706
713
  tenantRoot,
707
714
  scope: normalizedScope,
708
715
  serviceKey,
@@ -760,11 +767,12 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
760
767
  hostingKind,
761
768
  application,
762
769
  imageRefEnv,
763
- workspaceRoot: machineConfigRoot
770
+ workspaceRoot: machineConfigRoot,
771
+ identityOnly
764
772
  })
765
773
  ];
766
774
  }
767
- function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig, identity, hostingKind, application, imageRefEnv, workspaceRoot }) {
775
+ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig, identity, hostingKind, application, imageRefEnv, workspaceRoot, identityOnly = false }) {
768
776
  if (deployConfig.hosting?.kind !== "treeseed_control_plane") {
769
777
  return [];
770
778
  }
@@ -803,7 +811,9 @@ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig
803
811
  buildCommand: sourceMode === "git" ? railway.buildCommand ?? null : null,
804
812
  startCommand: sourceMode === "git" ? railway.startCommand ?? null : null
805
813
  };
806
- assertApiRailwaySourcePolicy(scope, service);
814
+ if (!identityOnly) {
815
+ assertApiRailwaySourcePolicy(scope, service);
816
+ }
807
817
  return {
808
818
  key: service.key,
809
819
  instanceKey: serviceName,
@@ -957,9 +967,9 @@ function resolveRailwayCapacityProviderRoot(tenantRoot, service) {
957
967
  );
958
968
  return found ?? resolve(tenantRoot, "packages", "agent");
959
969
  }
960
- function configuredRailwayServices(tenantRoot, scope, envOverlay = {}) {
970
+ function configuredRailwayServices(tenantRoot, scope, envOverlay = {}, options = {}) {
961
971
  const deployConfig = loadCliDeployConfig(tenantRoot);
962
- const direct = configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, null, tenantRoot, envOverlay);
972
+ const direct = configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, null, tenantRoot, envOverlay, options);
963
973
  const nested = discoverTreeseedApplications(tenantRoot).filter((application) => application.root !== resolve(tenantRoot)).flatMap((application) => configuredRailwayServicesForConfig(
964
974
  application.root,
965
975
  scope,
@@ -970,7 +980,8 @@ function configuredRailwayServices(tenantRoot, scope, envOverlay = {}) {
970
980
  relativeRoot: application.relativeRoot
971
981
  },
972
982
  tenantRoot,
973
- envOverlay
983
+ envOverlay,
984
+ options
974
985
  ));
975
986
  return [...direct, ...nested];
976
987
  }
@@ -42,6 +42,7 @@ import {
42
42
  import {
43
43
  configuredRailwayServices,
44
44
  findStaleTreeseedOperationsRunnerResources,
45
+ isTreeseedOperationsRunnerResourceName,
45
46
  validateRailwayDeployPrerequisites
46
47
  } from "../operations/services/railway-deploy.js";
47
48
  import { shouldExposeManagedHostRuntimeSecret } from "../operations/services/managed-host-security.js";
@@ -54,6 +55,7 @@ import {
54
55
  ensureRailwayServiceVolume,
55
56
  ensureRailwayPostgresService,
56
57
  deleteRailwayService,
58
+ deleteRailwayCustomDomain,
57
59
  deleteRailwayVolume,
58
60
  deployRailwayServiceInstance,
59
61
  inspectRailwayServiceDeploymentHealth,
@@ -2372,6 +2374,48 @@ async function ensureRailwayCustomDomain(input, service, domain, env, identifier
2372
2374
  if (matched) {
2373
2375
  return matched;
2374
2376
  }
2377
+ const environmentServices = await listRailwayEnvironmentServices({
2378
+ environmentId: identifiers.environmentId,
2379
+ env
2380
+ });
2381
+ for (const candidate of environmentServices) {
2382
+ const candidateServiceId = typeof candidate.id === "string" ? candidate.id.trim() : "";
2383
+ if (!candidateServiceId || candidateServiceId === identifiers.serviceId) {
2384
+ continue;
2385
+ }
2386
+ const candidateDomains = await listRailwayCustomDomains({
2387
+ projectId: identifiers.projectId,
2388
+ environmentId: identifiers.environmentId,
2389
+ serviceId: candidateServiceId,
2390
+ env
2391
+ });
2392
+ const staleAttachment = candidateDomains.find((entry) => entry.domain === domain) ?? null;
2393
+ if (!staleAttachment) {
2394
+ continue;
2395
+ }
2396
+ if (!staleAttachment.id) {
2397
+ throw new Error(`Railway custom domain ${domain} is attached to service ${candidateServiceId}, but Railway did not return its domain id for exact-state reattachment.`);
2398
+ }
2399
+ await deleteRailwayCustomDomain({ domainId: staleAttachment.id, env });
2400
+ let detached = false;
2401
+ for (let attempt = 0; attempt < 12; attempt += 1) {
2402
+ const remaining = await listRailwayCustomDomains({
2403
+ projectId: identifiers.projectId,
2404
+ environmentId: identifiers.environmentId,
2405
+ serviceId: candidateServiceId,
2406
+ env
2407
+ });
2408
+ if (!remaining.some((entry) => entry.domain === domain)) {
2409
+ detached = true;
2410
+ break;
2411
+ }
2412
+ await new Promise((resolve2) => setTimeout(resolve2, 1e3));
2413
+ }
2414
+ if (!detached) {
2415
+ throw new Error(`Railway custom domain ${domain} remained attached to service ${candidateServiceId} after deletion.`);
2416
+ }
2417
+ break;
2418
+ }
2375
2419
  const ensured = await ensureRailwayCustomDomainViaApi({
2376
2420
  projectId: identifiers.projectId,
2377
2421
  environmentId: identifiers.environmentId,
@@ -3657,6 +3701,24 @@ function configuredRailwayProjectSyncGroups(input, scope, serviceKeys) {
3657
3701
  }
3658
3702
  return [...grouped.values()];
3659
3703
  }
3704
+ function configuredRailwaySiblingResourceNames(input, scope, projectName) {
3705
+ const siblingScope = scope === "prod" ? "staging" : scope === "staging" ? "prod" : null;
3706
+ if (!siblingScope) return [];
3707
+ const siblingServices = configuredRailwayServices(
3708
+ input.context.tenantRoot,
3709
+ siblingScope,
3710
+ resolveReconcileEnvironmentValues(input, siblingScope),
3711
+ { identityOnly: true }
3712
+ ).filter((service) => service.enabled !== false).filter((service) => !isRailwayCapacityProviderService(service)).filter((service) => service.projectName === projectName);
3713
+ const database = configuredRailwayIacDatabase(input, siblingServices);
3714
+ return [.../* @__PURE__ */ new Set([
3715
+ ...siblingServices.flatMap((service) => [
3716
+ service.serviceName,
3717
+ ...service.volumeMountPath ? [`${service.serviceName}-volume`] : []
3718
+ ]),
3719
+ ...database ? [database.serviceName, `${database.serviceName}-volume`] : []
3720
+ ])];
3721
+ }
3660
3722
  function railwayIacServiceInput(input, sync, service, scope) {
3661
3723
  if (scope === "staging" && service.imageRef) {
3662
3724
  throw new Error(`Railway staging service ${service.serviceName} must build from Git source, not Docker image ${service.imageRef}.`);
@@ -3766,13 +3828,16 @@ async function reconcileStaleOperationsRunnerResourcesForScope(input, topology)
3766
3828
  );
3767
3829
  const projects = new Map(projectEntries.map((entry) => [entry.project.id, entry]));
3768
3830
  for (const { project, environment } of projects.values()) {
3831
+ const siblingResourceNames = configuredRailwaySiblingResourceNames(input, scope, project.name);
3832
+ const retainedSiblingServiceNames = siblingResourceNames.filter((name) => !name.endsWith("-volume")).filter(isTreeseedOperationsRunnerResourceName);
3833
+ const retainedSiblingVolumeNames = siblingResourceNames.filter((name) => name.endsWith("-volume")).filter(isTreeseedOperationsRunnerResourceName);
3769
3834
  await reconcileStaleOperationsRunnerResourcesForProject(input, {
3770
3835
  scope,
3771
3836
  env: topology.env,
3772
3837
  project,
3773
3838
  environment,
3774
- desiredServiceNames,
3775
- desiredVolumeNames
3839
+ desiredServiceNames: /* @__PURE__ */ new Set([...desiredServiceNames, ...retainedSiblingServiceNames]),
3840
+ desiredVolumeNames: /* @__PURE__ */ new Set([...desiredVolumeNames, ...retainedSiblingVolumeNames])
3776
3841
  });
3777
3842
  }
3778
3843
  }
@@ -3931,11 +3996,13 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3931
3996
  })),
3932
3997
  database: databaseForIac
3933
3998
  };
3999
+ const siblingResourceNames = configuredRailwaySiblingResourceNames(input, scope, project.name);
3934
4000
  const {
3935
4001
  applyRailwayIacProject,
3936
4002
  cleanupRailwayIacRender,
3937
4003
  planRailwayIacProject,
3938
4004
  renderRailwayIacProject,
4005
+ selectRailwayIacRetainedResources,
3939
4006
  validateRailwayIacChangeSet
3940
4007
  } = await import("./providers/railway-iac.js");
3941
4008
  let effectiveIacInput = iacInput;
@@ -3986,6 +4053,33 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3986
4053
  ]))
3987
4054
  });
3988
4055
  }
4056
+ const retainedResources = selectRailwayIacRetainedResources(plan, siblingResourceNames);
4057
+ if (retainedResources.length > 0) {
4058
+ traceRailwayReconcile(
4059
+ topology.env,
4060
+ "sync:iac-retain-sibling-environment",
4061
+ `${project.name}/${environment.name}:${retainedResources.map((resource) => resource.name).join(",")}`
4062
+ );
4063
+ cleanupRailwayIacRender(rendered);
4064
+ effectiveIacInput = { ...effectiveIacInput, retainedResources };
4065
+ rendered = renderRailwayIacProject(effectiveIacInput);
4066
+ plan = await planRailwayIacProject(effectiveIacInput, rendered);
4067
+ if (!plan.ok) {
4068
+ const diagnostics = plan.diagnostics.map((entry) => entry.message).filter(Boolean).join("; ");
4069
+ throw new Error(`Railway IaC plan failed for ${project.name}/${environment.name}${diagnostics ? `: ${diagnostics}` : "."}`);
4070
+ }
4071
+ validation = validateRailwayIacChangeSet(plan.changeSet, {
4072
+ services: rendered.serviceNames,
4073
+ volumes: rendered.volumeNames,
4074
+ database: rendered.databaseName,
4075
+ scope,
4076
+ serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null])),
4077
+ serviceSourceRefs: Object.fromEntries(effectiveIacInput.services.map((service) => [
4078
+ service.serviceName,
4079
+ service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
4080
+ ]))
4081
+ });
4082
+ }
3989
4083
  if (!validation.ok) {
3990
4084
  throw new Error(`Railway IaC plan rejected for ${project.name}/${environment.name}: ${validation.blockedReasons.join("; ")}`);
3991
4085
  }
@@ -1,4 +1,4 @@
1
- import { type RailwayChangeSet, type RailwayIacApplyResponse, type RailwayIacPlanResponse } from 'railway/iac';
1
+ import { type RailwayChangeSet, type RailwayIacApplyResponse, type RailwayIacPlanResponse, type ResourceNode } from 'railway/iac';
2
2
  export type TreeseedRailwayIacService = {
3
3
  key: string;
4
4
  serviceName: string;
@@ -37,6 +37,7 @@ export type TreeseedRailwayIacProjectInput = {
37
37
  railwayApiUrl?: string | null;
38
38
  services: TreeseedRailwayIacService[];
39
39
  database: TreeseedRailwayIacDatabase | null;
40
+ retainedResources?: ResourceNode[];
40
41
  region?: string | null;
41
42
  };
42
43
  export type TreeseedRailwayIacRenderResult = {
@@ -47,6 +48,7 @@ export type TreeseedRailwayIacRenderResult = {
47
48
  serviceNames: string[];
48
49
  volumeNames: string[];
49
50
  databaseName: string | null;
51
+ retainedResourceNames: string[];
50
52
  source: string;
51
53
  };
52
54
  export type RailwayIacValidationResult = {
@@ -56,6 +58,7 @@ export type RailwayIacValidationResult = {
56
58
  allowedDrift: string[];
57
59
  };
58
60
  export declare function renderRailwayIacProject(input: TreeseedRailwayIacProjectInput): TreeseedRailwayIacRenderResult;
61
+ export declare function selectRailwayIacRetainedResources(plan: Pick<RailwayIacPlanResponse, 'changeSet' | 'currentGraph'>, allowedNames: Iterable<string>): ResourceNode[];
59
62
  export declare function validateRailwayIacChangeSet(changeSet: RailwayChangeSet | undefined, desiredNames: {
60
63
  services: string[];
61
64
  volumes: string[];
@@ -1,6 +1,8 @@
1
1
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { runRailwayIac } from "railway/iac";
3
+ import {
4
+ runRailwayIac
5
+ } from "railway/iac";
4
6
  import { assertApiRailwaySourcePolicy, isApiRailwaySourcePolicyService } from "../../operations/services/railway-source-policy.js";
5
7
  function js(value) {
6
8
  return JSON.stringify(value);
@@ -115,6 +117,16 @@ function renderRailwayIacProject(input) {
115
117
  const volumeNames = [];
116
118
  const databaseVariableName = input.database ? "db" : null;
117
119
  const databaseEnvName = input.database?.environmentVariable ?? null;
120
+ const desiredResourceNames = /* @__PURE__ */ new Set([
121
+ ...input.services.map((service) => service.serviceName),
122
+ ...input.services.filter((service) => service.volumeMountPath).map((service) => `${service.serviceName}-volume`),
123
+ ...input.database ? [input.database.serviceName, `${input.database.serviceName}-volume`] : []
124
+ ]);
125
+ const retainedResources = (input.retainedResources ?? []).filter((resource) => !desiredResourceNames.has(resource.name));
126
+ if (retainedResources.length > 0) {
127
+ declarations.push(` const retainedResources = ${js(retainedResources)};`);
128
+ resources.push("...retainedResources");
129
+ }
118
130
  if (input.database) {
119
131
  const postgresVolumeName = `${input.database.serviceName}-volume`;
120
132
  const postgresMountPath = input.database.mountPath?.trim() || "/var/lib/postgresql/data";
@@ -205,9 +217,15 @@ ${declarations.join("\n")}
205
217
  serviceNames: input.services.map((service) => service.serviceName),
206
218
  volumeNames,
207
219
  databaseName: input.database?.serviceName ?? null,
220
+ retainedResourceNames: retainedResources.map((resource) => resource.name),
208
221
  source
209
222
  };
210
223
  }
224
+ function selectRailwayIacRetainedResources(plan, allowedNames) {
225
+ const allowed = new Set(allowedNames);
226
+ const deletedAllowedNames = new Set((plan.changeSet?.changes ?? []).filter((change) => change.kind === "resource.delete").map((change) => changeName(change)).filter((name) => allowed.has(name)));
227
+ return (plan.currentGraph?.resources ?? []).filter((resource) => deletedAllowedNames.has(resource.name));
228
+ }
211
229
  function changeName(change) {
212
230
  return String(change?.resource?.name ?? change?.previous?.name ?? change?.address ?? change?.path ?? "");
213
231
  }
@@ -322,5 +340,6 @@ export {
322
340
  cleanupRailwayIacRender,
323
341
  planRailwayIacProject,
324
342
  renderRailwayIacProject,
343
+ selectRailwayIacRetainedResources,
325
344
  validateRailwayIacChangeSet
326
345
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.12.54",
3
+ "version": "0.12.56",
4
4
  "description": "Shared Treeseed SDK for content-backed and D1-backed object models.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {