@noodleseed/one 0.168.0 → 0.169.0

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.
Files changed (38) hide show
  1. package/dist/commands/deployment-delete-ops.d.ts +12 -0
  2. package/dist/commands/deployment-delete-ops.d.ts.map +1 -0
  3. package/dist/commands/deployment-delete-ops.js +230 -0
  4. package/dist/commands/deployment-delete-ops.js.map +1 -0
  5. package/dist/commands/deployment-delete-recovery.d.ts +35 -0
  6. package/dist/commands/deployment-delete-recovery.d.ts.map +1 -0
  7. package/dist/commands/deployment-delete-recovery.js +99 -0
  8. package/dist/commands/deployment-delete-recovery.js.map +1 -0
  9. package/dist/commands/deployments-ops.d.ts +2 -1
  10. package/dist/commands/deployments-ops.d.ts.map +1 -1
  11. package/dist/commands/deployments-ops.js +5 -1
  12. package/dist/commands/deployments-ops.js.map +1 -1
  13. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-tenant-resources.js +27 -1
  14. package/node_modules/@noodle-borg/control-plane/dist/delete-deployment.d.ts +44 -0
  15. package/node_modules/@noodle-borg/control-plane/dist/delete-deployment.js +105 -0
  16. package/node_modules/@noodle-borg/control-plane/dist/deployment-deletion-contracts.d.ts +20 -0
  17. package/node_modules/@noodle-borg/control-plane/dist/portable.d.ts +2 -0
  18. package/node_modules/@noodle-borg/control-plane/dist/portable.js +1 -0
  19. package/node_modules/@noodle-borg/service/dist/deployment-deletion.js +40 -0
  20. package/node_modules/@noodle-borg/service/dist/registry-compile.js +6 -0
  21. package/node_modules/@noodle-borg/service/dist/registry-deletion.js +75 -0
  22. package/node_modules/@noodle-borg/service/dist/registry-state.js +4 -2
  23. package/node_modules/@noodle-borg/service/dist/registry-targets.js +4 -0
  24. package/node_modules/@noodle-borg/service/dist/registry.d.ts +2 -0
  25. package/node_modules/@noodle-borg/service/dist/registry.js +7 -23
  26. package/node_modules/@noodle-borg/service/dist/routes/deploy-dispatch.js +15 -1
  27. package/node_modules/@noodle-borg/service/dist/routes/deployment-delete.js +53 -0
  28. package/node_modules/@noodle-borg/service/dist/store/in-memory.d.ts +2 -1
  29. package/node_modules/@noodle-borg/service/dist/store/in-memory.js +8 -0
  30. package/node_modules/@noodle-borg/service/dist/store/json-file-deletion.js +64 -0
  31. package/node_modules/@noodle-borg/service/dist/store/json-file.d.ts +2 -1
  32. package/node_modules/@noodle-borg/service/dist/store/json-file.js +23 -5
  33. package/node_modules/@noodle-borg/service/dist/store.d.ts +5 -1
  34. package/node_modules/@noodle-borg/wire-contracts/dist/deployment-deletion.d.ts +29 -0
  35. package/node_modules/@noodle-borg/wire-contracts/dist/deployment-deletion.js +33 -0
  36. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  37. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  38. package/package.json +1 -1
@@ -0,0 +1,105 @@
1
+ const ERRORS = {
2
+ deployment_not_found: 'Deployment not found.',
3
+ version_not_found: 'Version not found.',
4
+ active_deployment: 'Roll back to another deployment before deleting the live deployment, or delete the whole version.',
5
+ deployment_locked: 'Unlock this version before deleting it.',
6
+ app_archived: 'Restore the archived app before deleting its deployments.',
7
+ deployment_delete_conflict: 'The deployment history changed. Refresh and review it before deleting.',
8
+ };
9
+ /** One owner-authorized deletion path for every host; stores commit the exact selection atomically. */
10
+ export async function deleteDeploymentOperation(deps, input) {
11
+ const { actor: identity, scope: route } = input;
12
+ const org = route.kind === 'version' ? route.target.org : route.org;
13
+ const member = identity.superAdmin
14
+ ? undefined
15
+ : await deps.controlPlane.getOrgMember({ org, subject: identity.subject });
16
+ if (identity.developerGrantId !== undefined ||
17
+ (!identity.superAdmin && member?.role !== 'owner')) {
18
+ return {
19
+ ok: false,
20
+ status: 403,
21
+ code: 'organization_owner_required',
22
+ error: 'Only an organization owner can delete deployments or versions.',
23
+ };
24
+ }
25
+ let target;
26
+ let selection;
27
+ if (route.kind === 'deployment') {
28
+ const record = await deps.registry.getDeployment(route.org, route.deploymentId);
29
+ if (!record)
30
+ return {
31
+ ok: false,
32
+ status: 404,
33
+ code: 'deployment_not_found',
34
+ error: ERRORS.deployment_not_found,
35
+ };
36
+ target = { org: record.orgSlug, app: record.appSlug, env: record.environment };
37
+ selection = { kind: 'deployment', deploymentId: route.deploymentId };
38
+ }
39
+ else {
40
+ if (!input.expectedDeploymentIds?.length)
41
+ return {
42
+ ok: false,
43
+ status: 400,
44
+ code: 'invalid_deletion_confirmation',
45
+ error: 'Invalid version deletion confirmation.',
46
+ };
47
+ target = route.target;
48
+ selection = {
49
+ kind: 'version',
50
+ expectedDeploymentIds: input.expectedDeploymentIds,
51
+ ...(route.serverVersion === undefined ? {} : { serverVersion: route.serverVersion }),
52
+ };
53
+ }
54
+ const result = await deps.registry.deleteDeployments(target, selection);
55
+ const status = result.ok
56
+ ? 200
57
+ : result.code === 'deployment_not_found' || result.code === 'version_not_found'
58
+ ? 404
59
+ : 409;
60
+ let auditRecorded = true;
61
+ try {
62
+ await deps.audit.emit({
63
+ eventType: result.ok
64
+ ? route.kind === 'version'
65
+ ? 'deployment.version.deleted'
66
+ : 'deployment.deleted'
67
+ : 'deployment.delete.rejected',
68
+ org: target.org,
69
+ app: target.app,
70
+ env: target.env,
71
+ ...(route.kind === 'deployment' ? { deploymentId: route.deploymentId } : {}),
72
+ actorSubject: identity.subject,
73
+ ...(identity.email === undefined ? {} : { actorEmail: identity.email }),
74
+ decision: result.ok ? 'allow' : 'deny',
75
+ status,
76
+ ...(!result.ok ? { reasonCode: result.code } : {}),
77
+ details: {
78
+ scope: route.kind,
79
+ ...(route.kind === 'version' ? { serverVersion: route.serverVersion ?? 'legacy' } : {}),
80
+ ...(result.ok ? { deletedDeployments: result.deleted.length } : {}),
81
+ },
82
+ });
83
+ }
84
+ catch {
85
+ // Deletion has already committed. An audit outage must not claim the records still exist.
86
+ auditRecorded = false;
87
+ }
88
+ if (!result.ok)
89
+ return {
90
+ ok: false,
91
+ status: status,
92
+ code: result.code,
93
+ error: ERRORS[result.code],
94
+ };
95
+ return {
96
+ ok: true,
97
+ view: {
98
+ ok: true,
99
+ target,
100
+ deletedDeploymentIds: result.deleted.map((record) => record.deploymentId),
101
+ auditRecorded,
102
+ },
103
+ };
104
+ }
105
+ //# sourceMappingURL=delete-deployment.js.map
@@ -0,0 +1,20 @@
1
+ export type DeploymentDeleteSelection = {
2
+ readonly kind: 'deployment';
3
+ readonly deploymentId: string;
4
+ } | {
5
+ readonly kind: 'version';
6
+ readonly serverVersion?: string;
7
+ readonly expectedDeploymentIds: readonly string[];
8
+ };
9
+ export type DeploymentDeleteResult<Record extends {
10
+ readonly deploymentId: string;
11
+ } = {
12
+ readonly deploymentId: string;
13
+ }> = {
14
+ readonly ok: true;
15
+ readonly deleted: readonly Record[];
16
+ } | {
17
+ readonly ok: false;
18
+ readonly code: 'deployment_not_found' | 'version_not_found' | 'active_deployment' | 'deployment_locked' | 'app_archived' | 'deployment_delete_conflict';
19
+ };
20
+ //# sourceMappingURL=deployment-deletion-contracts.d.ts.map
@@ -1,8 +1,10 @@
1
1
  export { type AppPurgeReconciliationActor, type AppPurgeReconciliationConflictCode, AppPurgeReconciliationError, type AppPurgeReconciliationOperator, } from './app-purge-reconciliation-port.js';
2
2
  export type { ActiveMcpSubdomainClaim, ChangeMcpSubdomainInput, ControlPlaneIdentity, CreateOrgWithOwnerInput, McpSubdomainMutationResult, McpSubdomainSetting, OrganizationStore, OrgDomainRecord, OrgInvitationRecord, OrgMemberRecord, OrgOpenAIAppsChallengeRecord, OrgRecord, OrgRole, PersonalWorkspaceProvisionInput, PersonalWorkspaceProvisionResult, SignupAllowlistKind, SignupAllowlistRecord, WelcomeEmailRecord, } from './contracts.js';
3
3
  export { PersonalWorkspaceOwnerMutationError } from './contracts.js';
4
+ export { type DeploymentDeleteOperationResult, type DeploymentDeleteScope, type DeploymentDeletionDependencies, type DeploymentDeletionRegistry, deleteDeploymentOperation, } from './delete-deployment.js';
4
5
  export { allowAllGate, bearerToken, type ControlPlaneAuthResult, type DeployAuthGate, } from './deploy-auth.js';
5
6
  export { CompositeControlPlaneGate, type ControlPlaneSignupMode, GoogleControlPlaneGate, type GoogleControlPlaneGateOptions, type GoogleIdTokenVerifier, GoogleWorkloadControlPlaneGate, type GoogleWorkloadControlPlaneGateOptions, NoodleOAuthControlPlaneGate, type NoodleOAuthControlPlaneGateOptions, type SignupAuthorizer, } from './deploy-gates.js';
7
+ export type { DeploymentDeleteResult, DeploymentDeleteSelection, } from './deployment-deletion-contracts.js';
6
8
  export { InMemoryControlPlaneStore } from './in-memory-control-plane-store.js';
7
9
  export { bindInMemoryOrganizationStore, type InMemoryOrganizationOperations, InMemoryOrganizationStore, } from './in-memory-organization-store.js';
8
10
  export { InMemoryMcpSubdomainClaimStore, McpSubdomainCooldownError, McpSubdomainIdempotencyConflictError, McpSubdomainOwnerRequiredError, type McpSubdomainRouteRef, McpSubdomainUnavailableError, mcpSubdomainEndpointOptions, type ResolvedMcpTenantRef, resolveMcpSubdomainTenant, } from './mcp-subdomain-claims.js';
@@ -1,5 +1,6 @@
1
1
  export { AppPurgeReconciliationError, } from './app-purge-reconciliation-port.js';
2
2
  export { PersonalWorkspaceOwnerMutationError } from './contracts.js';
3
+ export { deleteDeploymentOperation, } from './delete-deployment.js';
3
4
  export { allowAllGate, bearerToken, } from './deploy-auth.js';
4
5
  export { CompositeControlPlaneGate, GoogleControlPlaneGate, GoogleWorkloadControlPlaneGate, NoodleOAuthControlPlaneGate, } from './deploy-gates.js';
5
6
  export { InMemoryControlPlaneStore } from './in-memory-control-plane-store.js';
@@ -0,0 +1,40 @@
1
+ import { normalizeServerVersion } from '@noodle-borg/module';
2
+ import { sameTenantRecord } from './deployment-versioning.js';
3
+ import { validateTenantRef } from './store/validate.js';
4
+ /** Shared, side-effect-free decision made inside each adapter's mutation boundary. */
5
+ export function planDeploymentDeletion(records, ref, selection) {
6
+ const safe = validateTenantRef(ref);
7
+ const version = selection.kind === 'version' && selection.serverVersion !== undefined
8
+ ? normalizeServerVersion(selection.serverVersion)
9
+ : undefined;
10
+ const deleted = records.filter((record) => sameTenantRecord(record, safe) &&
11
+ (selection.kind === 'deployment'
12
+ ? record.deploymentId === selection.deploymentId
13
+ : record.serverVersion === version));
14
+ if (deleted.length === 0)
15
+ return {
16
+ ok: false,
17
+ code: selection.kind === 'deployment' ? 'deployment_not_found' : 'version_not_found',
18
+ };
19
+ if (records.some((record) => record.orgSlug === safe.org &&
20
+ record.appSlug === safe.app &&
21
+ record.archivedAt !== undefined)) {
22
+ return { ok: false, code: 'app_archived' };
23
+ }
24
+ if (selection.kind === 'deployment') {
25
+ if (deleted.some((record) => record.active))
26
+ return { ok: false, code: 'active_deployment' };
27
+ }
28
+ else {
29
+ if (deleted.some((record) => record.active && record.deploymentLock !== undefined))
30
+ return { ok: false, code: 'deployment_locked' };
31
+ const expected = new Set(selection.expectedDeploymentIds);
32
+ if (expected.size !== selection.expectedDeploymentIds.length ||
33
+ expected.size !== deleted.length ||
34
+ deleted.some((record) => !expected.has(record.deploymentId))) {
35
+ return { ok: false, code: 'deployment_delete_conflict' };
36
+ }
37
+ }
38
+ return { ok: true, deleted };
39
+ }
40
+ //# sourceMappingURL=deployment-deletion.js.map
@@ -9,6 +9,7 @@ import { ManagedConfigBroker } from './credential-broker.js';
9
9
  import { deploymentCredentialBrokerOptions } from './credential-broker-options.js';
10
10
  import { deploymentRecordVersionError } from './deployment-record-version.js';
11
11
  import { normalizePersistedManifestForCompile } from './manifest-normalize.js';
12
+ import { registryRecordStillExists } from './registry-deletion.js';
12
13
  import { missingCapabilityErrors, missingSecretErrors, missingVariableErrors, } from './registry-helpers.js';
13
14
  import { servedTargetFor } from './registry-targets.js';
14
15
  import { createDeploymentStateConnector, } from './state-connector-factory.js';
@@ -252,6 +253,11 @@ export async function loadPersistedRegistryTarget(source, context) {
252
253
  if (record.active &&
253
254
  (await context.hasCustomerAuthConflict(record, built.served.artifact.server.auth)))
254
255
  return undefined;
256
+ const exists = state.store === undefined
257
+ ? state.records.has(deploymentId)
258
+ : await registryRecordStillExists(state, deploymentId);
259
+ if (!exists)
260
+ return undefined;
255
261
  const target = servedTargetFor(record, built.served, context.customerVerifierFactory);
256
262
  state.servers.set(deploymentId, target);
257
263
  state.records.set(deploymentId, record);
@@ -0,0 +1,75 @@
1
+ import { planDeploymentDeletion } from './deployment-deletion.js';
2
+ import { targetForPersistedRecord } from './registry-targets.js';
3
+ import { validateTenantRef } from './store/validate.js';
4
+ export function evictDeletedDeployments(state, ids) {
5
+ for (const id of ids) {
6
+ state.records.delete(id);
7
+ state.servers.delete(id);
8
+ }
9
+ for (const [key, id] of state.activeTenants)
10
+ if (ids.has(id))
11
+ state.activeTenants.delete(key);
12
+ }
13
+ export async function deleteRegistryDeployments(state, ref, selection) {
14
+ const safe = validateTenantRef(ref);
15
+ const result = state.store === undefined
16
+ ? planDeploymentDeletion([...state.records.values()], safe, selection)
17
+ : await state.store.deleteDeployments(safe, selection);
18
+ if (result.ok)
19
+ evictDeletedDeployments(state, new Set(result.deleted.map((record) => record.deploymentId)));
20
+ return result;
21
+ }
22
+ /** Async compilation must rejoin authority before publishing a result into the disposable cache. */
23
+ export async function registryRecordStillExists(state, deploymentId) {
24
+ const current = state.store === undefined
25
+ ? state.records.get(deploymentId)
26
+ : await state.store.get(deploymentId);
27
+ if (current !== undefined && current.archivedAt === undefined)
28
+ return true;
29
+ evictDeletedDeployments(state, new Set([deploymentId]));
30
+ return false;
31
+ }
32
+ /** Refresh warmed cache entries from authority and coalesce compilation on a cache miss. */
33
+ export async function readRegistryTarget(state, deploymentId, inflight, targetForRecord, load) {
34
+ if (state.records.get(deploymentId)?.archivedAt !== undefined)
35
+ return undefined;
36
+ if (state.servers.has(deploymentId)) {
37
+ const record = state.store
38
+ ? await state.store.get(deploymentId)
39
+ : state.records.get(deploymentId);
40
+ if (record === undefined)
41
+ evictDeletedDeployments(state, new Set([deploymentId]));
42
+ return record === undefined || record.archivedAt !== undefined
43
+ ? undefined
44
+ : targetForRecord(record);
45
+ }
46
+ const pending = inflight.get(deploymentId);
47
+ if (pending)
48
+ return pending;
49
+ const promise = load(deploymentId).finally(() => {
50
+ inflight.delete(deploymentId);
51
+ });
52
+ inflight.set(deploymentId, promise);
53
+ return promise;
54
+ }
55
+ /** No-store records are authority, so stale reconciliation must never repopulate absent records. */
56
+ export async function reconcileRegistryTarget(state, record, context) {
57
+ let loaded = false;
58
+ const target = await targetForPersistedRecord(record, {
59
+ ...context,
60
+ load: () => {
61
+ loaded = true;
62
+ return context.load();
63
+ },
64
+ records: state.records,
65
+ servers: state.servers,
66
+ recordStillPresent: () => state.store !== undefined || state.records.has(record.deploymentId),
67
+ });
68
+ // A cold load already checks store authority just before publication; warm reconciliation must too.
69
+ // No async presence snapshot in no-store mode: deletion and publication share one synchronous turn.
70
+ const exists = state.store === undefined
71
+ ? state.records.has(record.deploymentId)
72
+ : loaded || (await registryRecordStillExists(state, record.deploymentId));
73
+ return exists ? target : undefined;
74
+ }
75
+ //# sourceMappingURL=registry-deletion.js.map
@@ -166,8 +166,10 @@ export async function setRegistryDeploymentLock(state, ref, serverVersion, expec
166
166
  state.records.set(result.record.deploymentId, result.record);
167
167
  return result;
168
168
  }
169
- const active = await activeRecordVersion(state, safe, safeVersion);
170
- if (active === undefined)
169
+ const observed = await activeRecordVersion(state, safe, safeVersion);
170
+ // The lookup yields; a concurrent deletion or pointer move must win before any cache write.
171
+ const active = observed === undefined ? undefined : state.records.get(observed.deploymentId);
172
+ if (active === undefined || !active.active || active.archivedAt !== undefined)
171
173
  return { ok: false, reason: 'no_active_deployment' };
172
174
  if (active.deploymentId !== expectedDeploymentId)
173
175
  return { ok: false, reason: 'conflict' };
@@ -110,6 +110,8 @@ function bindAppPackageSnapshot(served, value) {
110
110
  }
111
111
  /** Reconcile one persisted record with its cached served target without trusting stale auth projections. */
112
112
  export async function targetForPersistedRecord(record, state) {
113
+ if (state.recordStillPresent?.() === false)
114
+ return undefined;
113
115
  const cached = state.servers.get(record.deploymentId);
114
116
  const cachedSchemaVersion = cached === undefined ? undefined : targetPolicyVersions.get(cached);
115
117
  state.records.set(record.deploymentId, record);
@@ -127,6 +129,8 @@ export async function targetForPersistedRecord(record, state) {
127
129
  (await state.hasCustomerAuthConflict(record, cached.served.artifact.server.auth))) {
128
130
  return undefined;
129
131
  }
132
+ if (state.recordStillPresent?.() === false)
133
+ return undefined;
130
134
  if (cachedSchemaVersion === record.schemaVersion && servedTargetMatchesRecord(cached, record))
131
135
  return cached;
132
136
  const reconciled = servedTargetFor(record, cached.served, state.customerVerifierFactory);
@@ -9,6 +9,7 @@ import type { ServedTarget } from '@noodle-borg/transport-http';
9
9
  import type { NativeRecordConnectorFactory } from './native-record-connector.js';
10
10
  import { type ActiveDeployProvenance } from './registry-helpers.js';
11
11
  import type { AccessUpdateOptions, AccessUpdateResult, DeployOptions, DeployPreflightResult, RecoverResult, RunDeployResult, ServerRegistryOptions } from './registry-types.js';
12
+ import type { DeploymentDeleteResult, DeploymentDeleteSelection } from './store.js';
12
13
  import { type AppArchiveResult, type AppRestoreResult, type AppSummary, type ArtifactStore, type ConfigStore, type CustomerAuthRestoreProjection, type DeploymentListFilter, type DeploymentLock, type DeploymentLockUpdateResult, type DeploymentStatus, type DeploymentSummary, type DeployRecord, type EnvSummary, type ProductionEnvironmentChange, type TenantRef } from './store.js';
13
14
  export type { RollbackResult } from '@noodle-borg/control-plane/portable';
14
15
  export type { DeploymentPackage } from './registry-package.js';
@@ -29,6 +30,7 @@ export declare class ServerRegistry {
29
30
  /** Read-only deploy validation used by the CLI before config writes, asset upload, or persistence. */
30
31
  preflightDeploy(tenant: TenantRef, manifest: string, options?: DeployOptions): Promise<DeployPreflightResult>;
31
32
  recover(): Promise<RecoverResult>;
33
+ deleteDeployments(ref: TenantRef, selection: DeploymentDeleteSelection): Promise<DeploymentDeleteResult>;
32
34
  get(deploymentId: string): Promise<ServedTarget | undefined>;
33
35
  /** Resolve a deployment-id data-plane route only while that exact record is actively serving. */
34
36
  getServing(deploymentId: string): Promise<ServedTarget | undefined>;
@@ -6,6 +6,7 @@ import { DeploymentPolicyChangedError, UnsupportedDeploymentRecordVersionError,
6
6
  import { defaultActiveRecord } from './deployment-versioning.js';
7
7
  import { updateRegistryAccess } from './registry-access.js';
8
8
  import { compilePersistedRegistryRecord, compileRegistryTarget, loadPersistedRegistryTarget, } from './registry-compile.js';
9
+ import { deleteRegistryDeployments, readRegistryTarget, reconcileRegistryTarget, } from './registry-deletion.js';
9
10
  import { createRegistryDeployRecord, deployedRecordResult, deploymentWriteVersionError, preflightRegistryDeploy, replacementDeploymentPolicy, resolveDeployAttempt, withDeploymentConfiguration, } from './registry-deploy-transaction.js';
10
11
  import { deploymentOwnerSubject } from './registry-helpers.js';
11
12
  import { registryDeploymentPackage, renderDeploymentPackageSnapshot } from './registry-package.js';
@@ -13,7 +14,7 @@ import { recoverRegistryRecords } from './registry-recover.js';
13
14
  import { rollbackDeployment, rollbackWithMappedErrors } from './registry-rollback.js';
14
15
  import { activeRecord, activeRecordVersion, persistDeployRecord, reconcilePlatformAccountResetRegistryCache, registryAppArchivedAt, registryArchiveApp, registryCustomerAuthRestoreProjections, registryGetApp, registryGetDeployment, registryGetEnvironment, registryListApps, registryListEnvironments, registryRestoreApp, registrySetProductionEnvironment, registrySweepArchived, setRegistryDeploymentLock, } from './registry-state.js';
15
16
  import { deploymentStatusFor } from './registry-status.js';
16
- import { deploymentSourceFor, rebindNativeRecords, servedTargetFor, targetForPersistedRecord, tenantDeploymentKey, tenantKey, } from './registry-targets.js';
17
+ import { deploymentSourceFor, rebindNativeRecords, servedTargetFor, tenantDeploymentKey, tenantKey, } from './registry-targets.js';
17
18
  import { withBuiltinStateCatalog } from './state-catalog.js';
18
19
  import { deploymentSummary, matchesDeploymentFilter, validateDeploymentListFilter, } from './store/records.js';
19
20
  import { InMemoryConfigStore, validateTenantRef, } from './store.js';
@@ -318,26 +319,11 @@ export class ServerRegistry {
318
319
  #compilePersistedRecord(record) {
319
320
  return compilePersistedRegistryRecord(record, this.#compileTarget.bind(this));
320
321
  }
322
+ deleteDeployments(ref, selection) {
323
+ return deleteRegistryDeployments(this.#stateView(), ref, selection);
324
+ }
321
325
  async get(deploymentId) {
322
- if (this.#records.get(deploymentId)?.archivedAt !== undefined)
323
- return undefined;
324
- const cached = this.#servers.get(deploymentId);
325
- if (cached) {
326
- const record = this.#store
327
- ? await this.#store.get(deploymentId)
328
- : this.#records.get(deploymentId);
329
- return record === undefined || record.archivedAt !== undefined
330
- ? undefined
331
- : this.#targetForPersistedRecord(record);
332
- }
333
- const inflight = this.#inflight.get(deploymentId);
334
- if (inflight)
335
- return inflight;
336
- const promise = this.#loadAndCompile(deploymentId).finally(() => {
337
- this.#inflight.delete(deploymentId);
338
- });
339
- this.#inflight.set(deploymentId, promise);
340
- return promise;
326
+ return readRegistryTarget(this.#stateView(), deploymentId, this.#inflight, (record) => this.#targetForPersistedRecord(record), (id) => this.#loadAndCompile(id));
341
327
  }
342
328
  /** Resolve a deployment-id data-plane route only while that exact record is actively serving. */
343
329
  async getServing(deploymentId) {
@@ -499,9 +485,7 @@ export class ServerRegistry {
499
485
  return this.#knowledgeSearch;
500
486
  }
501
487
  async #targetForPersistedRecord(record) {
502
- return targetForPersistedRecord(record, {
503
- records: this.#records,
504
- servers: this.#servers,
488
+ return reconcileRegistryTarget(this.#stateView(), record, {
505
489
  ...(this.#customerVerifierFactory
506
490
  ? { customerVerifierFactory: this.#customerVerifierFactory }
507
491
  : {}),
@@ -2,9 +2,23 @@ import { applySecurityHeaders, enforceHttps, } from '@noodle-borg/transport-http
2
2
  import { respondRouteError } from '../http-util.js';
3
3
  import { handleDeploy } from './control-plane.js';
4
4
  import { handleDeployPreflight } from './deploy-preflight.js';
5
+ import { handleDeploymentDelete, parseVersionDeletePath } from './deployment-delete.js';
5
6
  import { handleDeploymentLockUpdate } from './deployment-lock.js';
6
- import { parseTenantDeploymentLockPath, parseTenantDeployPath, parseTenantDeployPreflightPath, } from './paths.js';
7
+ import { parseDeploymentItemPath, parseTenantDeploymentLockPath, parseTenantDeployPath, parseTenantDeployPreflightPath, } from './paths.js';
7
8
  export function dispatchDeployRoutes(req, res, url, deps) {
9
+ if (req.method === 'DELETE') {
10
+ const item = parseDeploymentItemPath(url.pathname);
11
+ const deletion = item
12
+ ? { kind: 'deployment', ...item }
13
+ : parseVersionDeletePath(url.pathname);
14
+ if (deletion) {
15
+ applySecurityHeaders(res, deps.tls);
16
+ if (enforceHttps(req, res, deps.tls))
17
+ return true;
18
+ handleDeploymentDelete(req, res, deletion, { ...deps, maxBody: deps.maxBody.control }).catch((error) => respondRouteError(deps.logger, res, 'deployment-delete.error', error));
19
+ return true;
20
+ }
21
+ }
8
22
  const deploymentLock = parseTenantDeploymentLockPath(url.pathname);
9
23
  if (req.method === 'PATCH' && deploymentLock !== undefined) {
10
24
  applySecurityHeaders(res, deps.tls);
@@ -0,0 +1,53 @@
1
+ import { deleteDeploymentOperation, } from '@noodle-borg/control-plane/portable';
2
+ import { normalizeServerVersion } from '@noodle-borg/module';
3
+ import { readJsonBody, sendJson } from '@noodle-borg/transport-http';
4
+ import { deploymentDeleteResponseSchema, deploymentVersionDeleteRequestSchema, } from '@noodle-borg/wire-contracts';
5
+ import { validateTenantRef } from '../store.js';
6
+ import { authorizeControlPlane } from './control-plane.js';
7
+ /** An exact numeric version, or the legacy unversioned scope; never semantic-version equivalence. */
8
+ export function parseVersionDeletePath(pathname) {
9
+ const match = /^\/v1\/orgs\/([^/]+)\/apps\/([^/]+)\/envs\/([^/]+)\/versions\/([^/]+)$/.exec(pathname);
10
+ if (!match)
11
+ return undefined;
12
+ try {
13
+ const target = validateTenantRef({
14
+ org: decodeURIComponent(match[1]),
15
+ app: decodeURIComponent(match[2]),
16
+ env: decodeURIComponent(match[3]),
17
+ });
18
+ const version = decodeURIComponent(match[4]);
19
+ return {
20
+ kind: 'version',
21
+ org: target.org,
22
+ target,
23
+ ...(version === 'legacy' ? {} : { serverVersion: normalizeServerVersion(version) }),
24
+ };
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ }
30
+ export async function handleDeploymentDelete(req, res, route, deps) {
31
+ const identity = await authorizeControlPlane(req, res, deps.gate, { requireIdentity: true });
32
+ if (identity === false)
33
+ return;
34
+ let expectedDeploymentIds;
35
+ if (route.kind === 'version') {
36
+ const body = await readJsonBody(req, deps.maxBody);
37
+ if (!body.ok)
38
+ return sendJson(res, body.status, { error: body.error });
39
+ const parsed = deploymentVersionDeleteRequestSchema.safeParse(body.value);
40
+ if (!parsed.success)
41
+ return sendJson(res, 400, { error: 'Invalid version deletion confirmation.' });
42
+ expectedDeploymentIds = parsed.data.expectedDeploymentIds;
43
+ }
44
+ const result = await deleteDeploymentOperation(deps, {
45
+ actor: identity,
46
+ scope: route,
47
+ ...(expectedDeploymentIds === undefined ? {} : { expectedDeploymentIds }),
48
+ });
49
+ return result.ok
50
+ ? sendJson(res, 200, deploymentDeleteResponseSchema.parse(result.view))
51
+ : sendJson(res, result.status, { code: result.code, error: result.error });
52
+ }
53
+ //# sourceMappingURL=deployment-delete.js.map
@@ -1,7 +1,8 @@
1
- import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
1
+ import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentDeleteResult, DeploymentDeleteSelection, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
2
2
  /** In-memory store: keeps records in a Map. Used by tests and as an explicit, non-persistent option. */
3
3
  export declare class InMemoryArtifactStore implements ArtifactStore {
4
4
  #private;
5
+ deleteDeployments(ref: TenantRef, selection: DeploymentDeleteSelection): Promise<DeploymentDeleteResult>;
5
6
  append(record: DeployRecord, precondition?: DeploymentPolicyPrecondition): Promise<void>;
6
7
  loadAll(): Promise<readonly DeployRecord[]>;
7
8
  get(deploymentId: string): Promise<DeployRecord | undefined>;
@@ -2,6 +2,7 @@ import { normalizeServerVersion } from '@noodle-borg/module';
2
2
  import { assertSameAppPackageSnapshot, sanitizeDeployRecordAppPackageSnapshot, } from '../app-package-snapshot.js';
3
3
  import { assertCustomerAuthRestorePrecondition, assertUniqueActiveCustomerAuthAudienceBindings, findActiveCustomerAuthAudienceConflict, requiresCustomerAuthProjection, } from '../customer-auth-audience-binding.js';
4
4
  import { matchesDeploymentActivation } from '../deployment-activation-precondition.js';
5
+ import { planDeploymentDeletion } from '../deployment-deletion.js';
5
6
  import { assertDeploymentActivationUnlocked, assertDeploymentAppendUnlocked, } from '../deployment-lock.js';
6
7
  import { assertDeploymentAppendPolicy, assertDeploymentAppendVersion, } from '../deployment-record-version.js';
7
8
  import { defaultActiveRecord, sameDeploymentScope, sameTenantRecord, } from '../deployment-versioning.js';
@@ -11,6 +12,13 @@ import { validateSlug, validateTenantRef } from './validate.js';
11
12
  export class InMemoryArtifactStore {
12
13
  #records = new Map();
13
14
  #productionEnvironments = new Map();
15
+ deleteDeployments(ref, selection) {
16
+ const result = planDeploymentDeletion([...this.#records.values()], ref, selection);
17
+ if (result.ok)
18
+ for (const record of result.deleted)
19
+ this.#records.delete(record.deploymentId);
20
+ return Promise.resolve(result);
21
+ }
14
22
  async append(record, precondition) {
15
23
  assertDeploymentAppendVersion([...this.#records.values()], record);
16
24
  assertDeploymentAppendPolicy([...this.#records.values()], record, precondition);
@@ -0,0 +1,64 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { DEPLOYMENT_ID_PATTERN } from './validate.js';
5
+ const lifecycleTails = new Map();
6
+ /** All adapters for one local directory join the same process-local lifecycle ordering. */
7
+ export function serializeFileLifecycle(directory, operation) {
8
+ const key = resolve(directory);
9
+ const result = (lifecycleTails.get(key) ?? Promise.resolve()).then(operation, operation);
10
+ const tail = result.then(() => undefined, () => undefined);
11
+ lifecycleTails.set(key, tail);
12
+ void tail.then(() => {
13
+ if (lifecycleTails.get(key) === tail)
14
+ lifecycleTails.delete(key);
15
+ });
16
+ return result;
17
+ }
18
+ /** Fail closed on an unreadable deletion journal; missing means no deletions have committed. */
19
+ export async function readDeletedDeploymentIds(directory) {
20
+ let text;
21
+ try {
22
+ text = await readFile(join(directory, 'deleted-deployments.json'), 'utf8');
23
+ }
24
+ catch (error) {
25
+ if (error.code === 'ENOENT')
26
+ return new Set();
27
+ throw error;
28
+ }
29
+ const ids = JSON.parse(text);
30
+ if (!Array.isArray(ids) ||
31
+ ids.some((id) => typeof id !== 'string' || !DEPLOYMENT_ID_PATTERN.test(id))) {
32
+ throw new Error('Invalid deployment deletion journal');
33
+ }
34
+ return new Set(ids);
35
+ }
36
+ /** Rename is the logical commit; retained ID tombstones also reject retries that would resurrect records. */
37
+ export async function commitDeletedDeploymentIds(directory, ids) {
38
+ const deleted = new Set(await readDeletedDeploymentIds(directory));
39
+ for (const id of ids)
40
+ deleted.add(id);
41
+ await mkdir(directory, { recursive: true });
42
+ const path = join(directory, 'deleted-deployments.json');
43
+ const temporary = `${path}.${randomUUID()}.tmp`;
44
+ const file = await open(temporary, 'wx', 0o600);
45
+ try {
46
+ await file.writeFile(JSON.stringify([...deleted].sort()));
47
+ await file.sync();
48
+ }
49
+ finally {
50
+ await file.close();
51
+ }
52
+ await rename(temporary, path);
53
+ // Persist both the commit rename and the metadata directory before removing any deployment files.
54
+ for (const parent of [directory, dirname(directory)]) {
55
+ const handle = await open(parent, 'r');
56
+ try {
57
+ await handle.sync();
58
+ }
59
+ finally {
60
+ await handle.close();
61
+ }
62
+ }
63
+ }
64
+ //# sourceMappingURL=json-file-deletion.js.map
@@ -1,4 +1,4 @@
1
- import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
1
+ import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition, AppRestoreResult, AppSummary, ArtifactStore, DeploymentActivationPrecondition, DeploymentActivationResult, DeploymentDeleteResult, DeploymentDeleteSelection, DeploymentListFilter, DeploymentLock, DeploymentLockUpdateResult, DeploymentPolicyPrecondition, DeploymentSummary, DeployRecord, EnvSummary, ProductionEnvironmentChange, TenantAuthConfig, TenantRef } from '../store.js';
2
2
  /**
3
3
  * JSON-file store: one file per deployment at `<dataDir>/deployments/<deploymentId>.json`, written atomically
4
4
  * (write a temp file, then `rename` over the target — no partial file is ever read). `deploymentId` is a
@@ -8,6 +8,7 @@ import type { ActiveAccessUpdateInput, AppArchiveResult, AppRestorePrecondition,
8
8
  export declare class JsonFileArtifactStore implements ArtifactStore {
9
9
  #private;
10
10
  constructor(dataDir: string);
11
+ deleteDeployments(ref: TenantRef, selection: DeploymentDeleteSelection): Promise<DeploymentDeleteResult>;
11
12
  append(record: DeployRecord, precondition?: DeploymentPolicyPrecondition): Promise<void>;
12
13
  get(deploymentId: string): Promise<DeployRecord | undefined>;
13
14
  getActiveByTenant(ref: TenantRef): Promise<DeployRecord | undefined>;