@heroku/sdk 0.12.2 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/resources/data/database-credential/index.d.ts +0 -1
- package/dist/resources/data/database-credential/index.js +0 -1
- package/dist/resources/data/database-credential/list.d.ts +0 -15
- package/dist/resources/data/database-credential/list.js +2 -20
- package/dist/resources/data/database-credential/rotate.d.ts +1 -3
- package/dist/resources/data/internal/postgres-utils.d.ts +27 -11
- package/dist/resources/data/internal/postgres-utils.js +19 -7
- package/dist/resources/data/internal/resolve-pg-database.js +2 -2
- package/dist/resources/data/postgres-database.d.ts +107 -1
- package/dist/resources/data/postgres-database.js +207 -1
- package/dist/resources/data/postgres-migration.d.ts +182 -0
- package/dist/resources/data/postgres-migration.js +313 -0
- package/dist/resources/data/postgres-pool.d.ts +77 -0
- package/dist/resources/data/postgres-pool.js +50 -0
- package/dist/resources/data/postgres-quota.d.ts +45 -0
- package/dist/resources/data/postgres-quota.js +61 -0
- package/dist/resources/data/postgres-settings.d.ts +33 -0
- package/dist/resources/data/postgres-settings.js +47 -0
- package/dist/resources/data/postgres.d.ts +229 -0
- package/dist/resources/data/postgres.js +273 -0
- package/dist/resources/data/redis/index.d.ts +1 -1
- package/dist/resources/extensions/data.d.ts +5 -0
- package/dist/resources/extensions/data.js +5 -0
- package/dist/resources/platform/add-on/destroy-and-wait.d.ts +2 -3
- package/dist/resources/platform/add-on/destroy-and-wait.js +7 -1
- package/dist/resources/platform/add-on/errors.d.ts +35 -2
- package/dist/resources/platform/add-on/errors.js +52 -0
- package/dist/resources/platform/add-on/index.d.ts +3 -3
- package/dist/resources/platform/add-on/index.js +1 -1
- package/dist/resources/platform/add-on/types.d.ts +14 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { PostgresInfoResult, PostgresMigrationCreateOpts, PostgresMigrationCreateResult, PostgresMigrationInfoResult } from '@heroku/types/data';
|
|
2
|
+
import type { ResourceCtx } from '../../core/extend-resource.js';
|
|
3
|
+
import type { AddOnAttachmentWithInclusions } from '../platform/add-on-attachment/resolve.js';
|
|
4
|
+
/**
|
|
5
|
+
* A database migration record, as returned by `find`, `startWhenReady`, and
|
|
6
|
+
* `abortBeforeRun`. The create/prepare response
|
|
7
|
+
* (`PostgresMigrationCreateResult`) is structurally identical today — same
|
|
8
|
+
* fields, same closed `status` union — but keeps its own generated type rather
|
|
9
|
+
* than sharing this alias.
|
|
10
|
+
*/
|
|
11
|
+
export type Migration = PostgresMigrationInfoResult;
|
|
12
|
+
/** The closed migration-status union, re-derived so callers don't restate it. */
|
|
13
|
+
export type MigrationStatus = Migration['status'];
|
|
14
|
+
/** Request body for configuring a migration (the generated create opts). */
|
|
15
|
+
export type MigrationCreateBody = PostgresMigrationCreateOpts;
|
|
16
|
+
export type MigrationOptions = {
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The reason a migration create request was rejected client-side. A
|
|
21
|
+
* single-member discriminated union today; new guards add members here so
|
|
22
|
+
* callers keep exhaustive handling. Server-side validation rejections are not
|
|
23
|
+
* yet mapped onto this type — they surface as the raw API error.
|
|
24
|
+
*/
|
|
25
|
+
export type MigrationInvalidReason = 'source_equals_target';
|
|
26
|
+
/**
|
|
27
|
+
* A classic Postgres database eligible to be migrated *from*, with the names
|
|
28
|
+
* it's attached under on the app.
|
|
29
|
+
*/
|
|
30
|
+
export type MigrationSource = AddOnAttachmentWithInclusions['addon'] & {
|
|
31
|
+
attachmentNames: string[];
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* An Advanced Postgres database eligible to be migrated *to*, with its
|
|
35
|
+
* attachment names, its current `info` (for availability gating), and any
|
|
36
|
+
* migration currently targeting it. `info`/`migration` are absent when the
|
|
37
|
+
* database's `/info` or `/migrations` endpoint 404s (not yet a target).
|
|
38
|
+
*/
|
|
39
|
+
export type MigrationTarget = AddOnAttachmentWithInclusions['addon'] & {
|
|
40
|
+
attachmentNames: string[];
|
|
41
|
+
info?: PostgresInfoResult;
|
|
42
|
+
migration?: Migration;
|
|
43
|
+
};
|
|
44
|
+
export type MigrationContext = {
|
|
45
|
+
/** All migration records currently configured on the app's Advanced databases. */
|
|
46
|
+
migrations: Migration[];
|
|
47
|
+
/** Classic (non-Essential) databases that can be migration sources. */
|
|
48
|
+
sources: MigrationSource[];
|
|
49
|
+
/** Advanced databases that can be migration targets. */
|
|
50
|
+
targets: MigrationTarget[];
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Thrown by `create` when the migration inputs are invalid before any request
|
|
54
|
+
* fires. Carries a machine `reason` so callers can branch without string
|
|
55
|
+
* matching.
|
|
56
|
+
*/
|
|
57
|
+
export declare class MigrationInvalidError extends Error {
|
|
58
|
+
readonly reason: MigrationInvalidReason;
|
|
59
|
+
readonly id = "migration_invalid";
|
|
60
|
+
constructor(reason: MigrationInvalidReason, message: string);
|
|
61
|
+
get body(): {
|
|
62
|
+
id: string;
|
|
63
|
+
message: string;
|
|
64
|
+
reason: "source_equals_target";
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Thrown by `run` when the migration isn't in a startable state. Carries the
|
|
69
|
+
* migration (and thus its `status`) so callers can report the drift without
|
|
70
|
+
* re-fetching.
|
|
71
|
+
*/
|
|
72
|
+
export declare class MigrationNotRunnableError extends Error {
|
|
73
|
+
readonly migration: Migration;
|
|
74
|
+
readonly id = "migration_not_runnable";
|
|
75
|
+
constructor(migration: Migration);
|
|
76
|
+
get body(): {
|
|
77
|
+
id: string;
|
|
78
|
+
message: string;
|
|
79
|
+
status: "creating_target" | "preparing" | "migrating" | "completed" | "failed" | "cancelled" | "ready" | "promoting" | "unknown";
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The migration's status. Named `migrationStatus` (not `status`) to avoid
|
|
83
|
+
* shadowing the HTTP-status convention other `Error` subclasses in the SDK
|
|
84
|
+
* carry.
|
|
85
|
+
*/
|
|
86
|
+
get migrationStatus(): MigrationStatus;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Thrown by `cancel` when the migration isn't in a cancellable state. Carries
|
|
90
|
+
* the migration (and thus its `status`).
|
|
91
|
+
*/
|
|
92
|
+
export declare class MigrationNotCancellableError extends Error {
|
|
93
|
+
readonly migration: Migration;
|
|
94
|
+
readonly id = "migration_not_cancellable";
|
|
95
|
+
constructor(migration: Migration);
|
|
96
|
+
get body(): {
|
|
97
|
+
id: string;
|
|
98
|
+
message: string;
|
|
99
|
+
status: "creating_target" | "preparing" | "migrating" | "completed" | "failed" | "cancelled" | "ready" | "promoting" | "unknown";
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* The migration's status. Named `migrationStatus` (not `status`) to avoid
|
|
103
|
+
* shadowing the HTTP-status convention other `Error` subclasses in the SDK
|
|
104
|
+
* carry.
|
|
105
|
+
*/
|
|
106
|
+
get migrationStatus(): MigrationStatus;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether a migration can be started. Only `ready` migrations are runnable,
|
|
110
|
+
* matching the CLI's menu gate. The exhaustive `switch` makes a newly
|
|
111
|
+
* generated status a compile error until its policy is decided here, rather
|
|
112
|
+
* than silently defaulting to runnable or not.
|
|
113
|
+
*/
|
|
114
|
+
export declare function isRunnable(status: MigrationStatus): boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Whether a migration can be canceled. Only `ready` migrations are
|
|
117
|
+
* cancellable, matching the CLI's menu gate. Exhaustive for the same reason as
|
|
118
|
+
* {@link isRunnable}.
|
|
119
|
+
*/
|
|
120
|
+
export declare function isCancellable(status: MigrationStatus): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Fetches the migration currently targeting `targetAddonId`.
|
|
123
|
+
*
|
|
124
|
+
* The data API's migration endpoint is a single-object `GET` (not a list);
|
|
125
|
+
* when no migration exists it 404s, which this surfaces as `undefined` rather
|
|
126
|
+
* than throwing, so callers can treat "no migration" as a normal state.
|
|
127
|
+
*/
|
|
128
|
+
export declare function info(ctx: Pick<ResourceCtx, 'data'>, targetAddonId: string, options?: MigrationOptions): Promise<Migration | undefined>;
|
|
129
|
+
/**
|
|
130
|
+
* Configures a new migration *to* `targetAddonId` *from* the database named in
|
|
131
|
+
* `body.source_id`.
|
|
132
|
+
*
|
|
133
|
+
* The URL identifies the **target**; the body's `source_id` identifies the
|
|
134
|
+
* **source** — both are add-on ids, so the two are easy to swap. A
|
|
135
|
+
* client-side guard rejects a source that equals the target before any request
|
|
136
|
+
* fires.
|
|
137
|
+
*/
|
|
138
|
+
export declare function create(ctx: Pick<ResourceCtx, 'data'>, targetAddonId: string, body: MigrationCreateBody, options?: MigrationOptions): Promise<PostgresMigrationCreateResult>;
|
|
139
|
+
/**
|
|
140
|
+
* Starts the migration targeting `targetAddonId`.
|
|
141
|
+
*
|
|
142
|
+
* Fetches the migration first and gates on its status — a defensive backstop
|
|
143
|
+
* against state drift between listing and action: throws
|
|
144
|
+
* `MigrationNotRunnableError` (carrying the migration) if it's no longer
|
|
145
|
+
* `ready`, and lets a `NotFoundError` propagate if the migration is already
|
|
146
|
+
* gone. Once the run POST succeeds, returns the migration's post-action state
|
|
147
|
+
* via a best-effort re-fetch (see {@link refetchAfterAction}); never throws
|
|
148
|
+
* after the action has fired.
|
|
149
|
+
*/
|
|
150
|
+
export declare function run(ctx: Pick<ResourceCtx, 'data'>, targetAddonId: string, options?: MigrationOptions): Promise<Migration>;
|
|
151
|
+
/**
|
|
152
|
+
* Cancels the migration targeting `targetAddonId`.
|
|
153
|
+
*
|
|
154
|
+
* Gates on status like {@link run}: throws `MigrationNotCancellableError`
|
|
155
|
+
* (carrying the migration) if it's not `ready`, and lets a `NotFoundError`
|
|
156
|
+
* propagate if the migration is already gone. Once the cancel POST succeeds,
|
|
157
|
+
* returns the migration's post-action state via a best-effort re-fetch (see
|
|
158
|
+
* {@link refetchAfterAction}); never throws after the action has fired.
|
|
159
|
+
*/
|
|
160
|
+
export declare function cancel(ctx: Pick<ResourceCtx, 'data'>, targetAddonId: string, options?: MigrationOptions): Promise<Migration>;
|
|
161
|
+
/**
|
|
162
|
+
* Lists everything the migration UI needs for an app in one pass: the classic
|
|
163
|
+
* databases that can be sources, the Advanced databases that can be targets
|
|
164
|
+
* (each with its `info` and any migration targeting it), and the flat list of
|
|
165
|
+
* configured migrations.
|
|
166
|
+
*
|
|
167
|
+
* Replaces the CLI's `getAppDatabases` + `getMigrationTargetsAndInfo`: resolves
|
|
168
|
+
* the app's add-on attachments (with `addon:plan,config_vars` inclusions so
|
|
169
|
+
* plans are present for tier classification), keeps only Postgres databases
|
|
170
|
+
* the app owns, dedupes to unique add-ons with their attachment names, then
|
|
171
|
+
* fans out `/migrations` and `/info` per Advanced database. A per-database 404
|
|
172
|
+
* is tolerated (that database simply isn't a migration target yet); the first
|
|
173
|
+
* non-404 rejection is rethrown.
|
|
174
|
+
*/
|
|
175
|
+
export declare function listMigrationContext(ctx: Pick<ResourceCtx, 'data' | 'platform'>, appIdentity: string, options?: MigrationOptions): Promise<MigrationContext>;
|
|
176
|
+
export declare const postgresMigrationExtensions: import("../../core/extend-resource.js").ResourceExtension<"data", "postgresMigration", {
|
|
177
|
+
abortBeforeRun: (targetAddonId: string, options?: MigrationOptions) => Promise<PostgresMigrationInfoResult>;
|
|
178
|
+
find: (targetAddonId: string, options?: MigrationOptions) => Promise<PostgresMigrationInfoResult | undefined>;
|
|
179
|
+
listMigrationContext: (appIdentity: string, options?: MigrationOptions) => Promise<MigrationContext>;
|
|
180
|
+
prepare: (targetAddonId: string, body: MigrationCreateBody, options?: MigrationOptions) => Promise<PostgresMigrationCreateResult>;
|
|
181
|
+
startWhenReady: (targetAddonId: string, options?: MigrationOptions) => Promise<PostgresMigrationInfoResult>;
|
|
182
|
+
}>;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { NotFoundError } from '@heroku/heroku-fetch';
|
|
2
|
+
import { extendResource } from '../../core/extend-resource.js';
|
|
3
|
+
import { withSignal } from '../../core/with-signal.js';
|
|
4
|
+
import { isAdvancedDatabase, isEssentialDatabase, isLegacyDatabase, isPostgresAddon, } from './internal/postgres-utils.js';
|
|
5
|
+
/**
|
|
6
|
+
* Thrown by `create` when the migration inputs are invalid before any request
|
|
7
|
+
* fires. Carries a machine `reason` so callers can branch without string
|
|
8
|
+
* matching.
|
|
9
|
+
*/
|
|
10
|
+
export class MigrationInvalidError extends Error {
|
|
11
|
+
reason;
|
|
12
|
+
id = 'migration_invalid';
|
|
13
|
+
constructor(reason, message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.reason = reason;
|
|
16
|
+
this.name = 'MigrationInvalidError';
|
|
17
|
+
}
|
|
18
|
+
get body() {
|
|
19
|
+
return { id: this.id, message: this.message, reason: this.reason };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Thrown by `run` when the migration isn't in a startable state. Carries the
|
|
24
|
+
* migration (and thus its `status`) so callers can report the drift without
|
|
25
|
+
* re-fetching.
|
|
26
|
+
*/
|
|
27
|
+
export class MigrationNotRunnableError extends Error {
|
|
28
|
+
migration;
|
|
29
|
+
id = 'migration_not_runnable';
|
|
30
|
+
constructor(migration) {
|
|
31
|
+
super(`Migration ${migration.id} can't be started from status '${migration.status}'. Only migrations in the 'ready' status can be started.`);
|
|
32
|
+
this.migration = migration;
|
|
33
|
+
this.name = 'MigrationNotRunnableError';
|
|
34
|
+
}
|
|
35
|
+
get body() {
|
|
36
|
+
return { id: this.id, message: this.message, status: this.migrationStatus };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The migration's status. Named `migrationStatus` (not `status`) to avoid
|
|
40
|
+
* shadowing the HTTP-status convention other `Error` subclasses in the SDK
|
|
41
|
+
* carry.
|
|
42
|
+
*/
|
|
43
|
+
get migrationStatus() {
|
|
44
|
+
return this.migration.status;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Thrown by `cancel` when the migration isn't in a cancellable state. Carries
|
|
49
|
+
* the migration (and thus its `status`).
|
|
50
|
+
*/
|
|
51
|
+
export class MigrationNotCancellableError extends Error {
|
|
52
|
+
migration;
|
|
53
|
+
id = 'migration_not_cancellable';
|
|
54
|
+
constructor(migration) {
|
|
55
|
+
super(`Migration ${migration.id} can't be canceled from status '${migration.status}'. Only migrations in the 'ready' status can be canceled.`);
|
|
56
|
+
this.migration = migration;
|
|
57
|
+
this.name = 'MigrationNotCancellableError';
|
|
58
|
+
}
|
|
59
|
+
get body() {
|
|
60
|
+
return { id: this.id, message: this.message, status: this.migrationStatus };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The migration's status. Named `migrationStatus` (not `status`) to avoid
|
|
64
|
+
* shadowing the HTTP-status convention other `Error` subclasses in the SDK
|
|
65
|
+
* carry.
|
|
66
|
+
*/
|
|
67
|
+
get migrationStatus() {
|
|
68
|
+
return this.migration.status;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Compile-time exhaustiveness guard with a safe runtime fallback. A newly
|
|
73
|
+
* generated `MigrationStatus` that isn't handled above is a type error here
|
|
74
|
+
* (the `never` parameter), but if one reaches this at runtime — e.g. the API
|
|
75
|
+
* returns a status the type doesn't yet know about — we return `fallback`
|
|
76
|
+
* rather than throwing, so `run`/`cancel` fail closed (treat it as not
|
|
77
|
+
* runnable/cancellable) instead of crashing.
|
|
78
|
+
*/
|
|
79
|
+
function exhaustiveFallback(_status, fallback) {
|
|
80
|
+
return fallback;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether a migration can be started. Only `ready` migrations are runnable,
|
|
84
|
+
* matching the CLI's menu gate. The exhaustive `switch` makes a newly
|
|
85
|
+
* generated status a compile error until its policy is decided here, rather
|
|
86
|
+
* than silently defaulting to runnable or not.
|
|
87
|
+
*/
|
|
88
|
+
export function isRunnable(status) {
|
|
89
|
+
switch (status) {
|
|
90
|
+
case 'cancelled':
|
|
91
|
+
case 'completed':
|
|
92
|
+
case 'creating_target':
|
|
93
|
+
case 'failed':
|
|
94
|
+
case 'migrating':
|
|
95
|
+
case 'preparing':
|
|
96
|
+
case 'promoting':
|
|
97
|
+
case 'unknown': {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
case 'ready': {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
default: {
|
|
104
|
+
return exhaustiveFallback(status, false);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Whether a migration can be canceled. Only `ready` migrations are
|
|
110
|
+
* cancellable, matching the CLI's menu gate. Exhaustive for the same reason as
|
|
111
|
+
* {@link isRunnable}.
|
|
112
|
+
*/
|
|
113
|
+
export function isCancellable(status) {
|
|
114
|
+
switch (status) {
|
|
115
|
+
case 'cancelled':
|
|
116
|
+
case 'completed':
|
|
117
|
+
case 'creating_target':
|
|
118
|
+
case 'failed':
|
|
119
|
+
case 'migrating':
|
|
120
|
+
case 'preparing':
|
|
121
|
+
case 'promoting':
|
|
122
|
+
case 'unknown': {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
case 'ready': {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
default: {
|
|
129
|
+
return exhaustiveFallback(status, false);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Fetches the migration currently targeting `targetAddonId`.
|
|
135
|
+
*
|
|
136
|
+
* The data API's migration endpoint is a single-object `GET` (not a list);
|
|
137
|
+
* when no migration exists it 404s, which this surfaces as `undefined` rather
|
|
138
|
+
* than throwing, so callers can treat "no migration" as a normal state.
|
|
139
|
+
*/
|
|
140
|
+
export async function info(ctx, targetAddonId, options = {}) {
|
|
141
|
+
const { signal } = options;
|
|
142
|
+
signal?.throwIfAborted();
|
|
143
|
+
try {
|
|
144
|
+
return await withSignal(ctx.data, signal).postgresMigration.info(targetAddonId);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error instanceof NotFoundError)
|
|
148
|
+
return undefined;
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Configures a new migration *to* `targetAddonId` *from* the database named in
|
|
154
|
+
* `body.source_id`.
|
|
155
|
+
*
|
|
156
|
+
* The URL identifies the **target**; the body's `source_id` identifies the
|
|
157
|
+
* **source** — both are add-on ids, so the two are easy to swap. A
|
|
158
|
+
* client-side guard rejects a source that equals the target before any request
|
|
159
|
+
* fires.
|
|
160
|
+
*/
|
|
161
|
+
export async function create(ctx, targetAddonId, body, options = {}) {
|
|
162
|
+
const { signal } = options;
|
|
163
|
+
signal?.throwIfAborted();
|
|
164
|
+
if (body.source_id === targetAddonId) {
|
|
165
|
+
throw new MigrationInvalidError('source_equals_target', `A migration's source and target must differ, but both are '${targetAddonId}'.`);
|
|
166
|
+
}
|
|
167
|
+
return withSignal(ctx.data, signal).postgresMigration.create(targetAddonId, body);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Best-effort re-fetch of a migration after a successful `run`/`cancel` POST so
|
|
171
|
+
* the returned status reflects the transition the action kicked off.
|
|
172
|
+
*
|
|
173
|
+
* The POST already succeeded by the time this runs, so the re-fetch must never
|
|
174
|
+
* turn a completed action into a thrown error: the caller's `signal` is
|
|
175
|
+
* deliberately *not* threaded in (a post-POST abort shouldn't surface as a
|
|
176
|
+
* failure of an action that already happened), and any re-fetch error is
|
|
177
|
+
* swallowed. When the migration can't be re-read — it 404s (already gone) or
|
|
178
|
+
* the re-fetch fails for any other reason — we fall back to the gated record
|
|
179
|
+
* with its status forced to `'unknown'` rather than returning the stale
|
|
180
|
+
* `'ready'` the gate observed, which would wrongly imply the action never ran.
|
|
181
|
+
*/
|
|
182
|
+
async function refetchAfterAction(ctx, targetAddonId, gated) {
|
|
183
|
+
try {
|
|
184
|
+
const refreshed = await info(ctx, targetAddonId);
|
|
185
|
+
return refreshed ?? { ...gated, status: 'unknown' };
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return { ...gated, status: 'unknown' };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Starts the migration targeting `targetAddonId`.
|
|
193
|
+
*
|
|
194
|
+
* Fetches the migration first and gates on its status — a defensive backstop
|
|
195
|
+
* against state drift between listing and action: throws
|
|
196
|
+
* `MigrationNotRunnableError` (carrying the migration) if it's no longer
|
|
197
|
+
* `ready`, and lets a `NotFoundError` propagate if the migration is already
|
|
198
|
+
* gone. Once the run POST succeeds, returns the migration's post-action state
|
|
199
|
+
* via a best-effort re-fetch (see {@link refetchAfterAction}); never throws
|
|
200
|
+
* after the action has fired.
|
|
201
|
+
*/
|
|
202
|
+
export async function run(ctx, targetAddonId, options = {}) {
|
|
203
|
+
const { signal } = options;
|
|
204
|
+
signal?.throwIfAborted();
|
|
205
|
+
const data = withSignal(ctx.data, signal);
|
|
206
|
+
const migration = await data.postgresMigration.info(targetAddonId);
|
|
207
|
+
if (!isRunnable(migration.status)) {
|
|
208
|
+
throw new MigrationNotRunnableError(migration);
|
|
209
|
+
}
|
|
210
|
+
signal?.throwIfAborted();
|
|
211
|
+
await data.postgresMigration.run(targetAddonId);
|
|
212
|
+
return refetchAfterAction(ctx, targetAddonId, migration);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Cancels the migration targeting `targetAddonId`.
|
|
216
|
+
*
|
|
217
|
+
* Gates on status like {@link run}: throws `MigrationNotCancellableError`
|
|
218
|
+
* (carrying the migration) if it's not `ready`, and lets a `NotFoundError`
|
|
219
|
+
* propagate if the migration is already gone. Once the cancel POST succeeds,
|
|
220
|
+
* returns the migration's post-action state via a best-effort re-fetch (see
|
|
221
|
+
* {@link refetchAfterAction}); never throws after the action has fired.
|
|
222
|
+
*/
|
|
223
|
+
export async function cancel(ctx, targetAddonId, options = {}) {
|
|
224
|
+
const { signal } = options;
|
|
225
|
+
signal?.throwIfAborted();
|
|
226
|
+
const data = withSignal(ctx.data, signal);
|
|
227
|
+
const migration = await data.postgresMigration.info(targetAddonId);
|
|
228
|
+
if (!isCancellable(migration.status)) {
|
|
229
|
+
throw new MigrationNotCancellableError(migration);
|
|
230
|
+
}
|
|
231
|
+
signal?.throwIfAborted();
|
|
232
|
+
await data.postgresMigration.cancel(targetAddonId);
|
|
233
|
+
return refetchAfterAction(ctx, targetAddonId, migration);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Lists everything the migration UI needs for an app in one pass: the classic
|
|
237
|
+
* databases that can be sources, the Advanced databases that can be targets
|
|
238
|
+
* (each with its `info` and any migration targeting it), and the flat list of
|
|
239
|
+
* configured migrations.
|
|
240
|
+
*
|
|
241
|
+
* Replaces the CLI's `getAppDatabases` + `getMigrationTargetsAndInfo`: resolves
|
|
242
|
+
* the app's add-on attachments (with `addon:plan,config_vars` inclusions so
|
|
243
|
+
* plans are present for tier classification), keeps only Postgres databases
|
|
244
|
+
* the app owns, dedupes to unique add-ons with their attachment names, then
|
|
245
|
+
* fans out `/migrations` and `/info` per Advanced database. A per-database 404
|
|
246
|
+
* is tolerated (that database simply isn't a migration target yet); the first
|
|
247
|
+
* non-404 rejection is rethrown.
|
|
248
|
+
*/
|
|
249
|
+
export async function listMigrationContext(ctx, appIdentity, options = {}) {
|
|
250
|
+
const { signal } = options;
|
|
251
|
+
signal?.throwIfAborted();
|
|
252
|
+
const platform = withSignal(ctx.platform.withHeaders({ 'Accept-Inclusion': 'addon:plan,config_vars' }), signal);
|
|
253
|
+
const data = withSignal(ctx.data, signal);
|
|
254
|
+
const attachments = await platform.addOnAttachment.listByApp(appIdentity);
|
|
255
|
+
// Keep only Postgres databases this app *owns* (attachments to databases
|
|
256
|
+
// owned by other apps are excluded), then dedupe to unique add-ons while
|
|
257
|
+
// collecting the names each is attached under (insertion order preserved).
|
|
258
|
+
const byAddonId = new Map();
|
|
259
|
+
for (const attachment of attachments) {
|
|
260
|
+
const { addon } = attachment;
|
|
261
|
+
if (!isPostgresAddon(addon) || (addon.app?.name !== appIdentity && addon.app?.id !== appIdentity))
|
|
262
|
+
continue;
|
|
263
|
+
const existing = byAddonId.get(addon.id);
|
|
264
|
+
if (existing) {
|
|
265
|
+
existing.attachmentNames.push(attachment.name);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
byAddonId.set(addon.id, { ...addon, attachmentNames: [attachment.name] });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const databases = [...byAddonId.values()];
|
|
272
|
+
const sources = databases.filter(db => isLegacyDatabase(db) && !isEssentialDatabase(db));
|
|
273
|
+
const advanced = databases.filter(db => isAdvancedDatabase(db));
|
|
274
|
+
const [migrationResults, infoResults] = await Promise.all([
|
|
275
|
+
Promise.allSettled(advanced.map(db => data.postgresMigration.info(db.id))),
|
|
276
|
+
Promise.allSettled(advanced.map(db => data.postgres.info(db.id))),
|
|
277
|
+
]);
|
|
278
|
+
// A 404 means the database isn't a migration target yet (or has no info) —
|
|
279
|
+
// expected. Anything else is a real failure; rethrow the first one.
|
|
280
|
+
const unexpected = [...migrationResults, ...infoResults].find((result) => result.status === 'rejected' && !(result.reason instanceof NotFoundError));
|
|
281
|
+
if (unexpected)
|
|
282
|
+
throw unexpected.reason;
|
|
283
|
+
const targets = advanced.map((db, index) => {
|
|
284
|
+
const migrationResult = migrationResults[index];
|
|
285
|
+
const infoResult = infoResults[index];
|
|
286
|
+
return {
|
|
287
|
+
...db,
|
|
288
|
+
info: infoResult.status === 'fulfilled' ? infoResult.value : undefined,
|
|
289
|
+
migration: migrationResult.status === 'fulfilled' ? migrationResult.value : undefined,
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
const migrations = targets
|
|
293
|
+
.map(target => target.migration)
|
|
294
|
+
.filter((migration) => migration !== undefined);
|
|
295
|
+
return { migrations, sources, targets };
|
|
296
|
+
}
|
|
297
|
+
// These extension keys are intentionally named distinctly from the generated
|
|
298
|
+
// `postgresMigration` routes (`info`/`create`/`run`/`cancel`) they wrap.
|
|
299
|
+
// Registering an extension under a generated route's own name silently replaces
|
|
300
|
+
// that route (the replacement is only debug-logged), which both hides the
|
|
301
|
+
// orchestration behind a familiar-looking name and leaves no way to reach the
|
|
302
|
+
// raw route. The verbs here also describe the guarded behavior the raw routes
|
|
303
|
+
// lack: `find` returns undefined instead of throwing on 404; `prepare`
|
|
304
|
+
// configures (and validates) a migration without starting it; `startWhenReady`
|
|
305
|
+
// and `abortBeforeRun` gate on the migration still being in its pre-run `ready`
|
|
306
|
+
// state before acting, then re-fetch the post-action status.
|
|
307
|
+
export const postgresMigrationExtensions = extendResource('data', 'postgresMigration', ctx => ({
|
|
308
|
+
abortBeforeRun: (targetAddonId, options) => cancel(ctx, targetAddonId, options),
|
|
309
|
+
find: (targetAddonId, options) => info(ctx, targetAddonId, options),
|
|
310
|
+
listMigrationContext: (appIdentity, options) => listMigrationContext(ctx, appIdentity, options),
|
|
311
|
+
prepare: (targetAddonId, body, options) => create(ctx, targetAddonId, body, options),
|
|
312
|
+
startWhenReady: (targetAddonId, options) => run(ctx, targetAddonId, options),
|
|
313
|
+
}));
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { PostgresLevelInfoResult, PostgresPoolInfoResult } from '@heroku/types/data';
|
|
2
|
+
/** The maximum number of follower-pool instances a database cluster can hold. */
|
|
3
|
+
export declare const MAX_POOL_INSTANCES = 13;
|
|
4
|
+
/** The name the leader pool always carries; any other name is a follower pool. */
|
|
5
|
+
export declare const LEADER_POOL_NAME = "leader";
|
|
6
|
+
/** A single compute level from `postgresLevel.info`. */
|
|
7
|
+
export type LevelItem = PostgresLevelInfoResult['items'][number];
|
|
8
|
+
/**
|
|
9
|
+
* A level paired with whether it's selectable for a given pool. The level a
|
|
10
|
+
* pool is already at is reported unavailable (`reason: 'current_level'`),
|
|
11
|
+
* mirroring the CLI's "current level" disabling; every other level is
|
|
12
|
+
* available. Callers get the full `LevelItem` (vcpu/memory/connection_limit),
|
|
13
|
+
* not just a name, so they can render without a second lookup.
|
|
14
|
+
*/
|
|
15
|
+
export type AvailableLevel = {
|
|
16
|
+
level: LevelItem;
|
|
17
|
+
} & ({
|
|
18
|
+
available: false;
|
|
19
|
+
reason: 'current_level';
|
|
20
|
+
} | {
|
|
21
|
+
available: true;
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* An action that can be taken on a pool. A discriminated union on `type`:
|
|
25
|
+
* `change_level` applies to any pool; `add_ha`/`remove_ha` are the leader's
|
|
26
|
+
* HA toggle (carrying the instance `count` the mutation sets); `update_count`
|
|
27
|
+
* (carrying the selectable `min`/`max`) and `destroy` apply to follower pools.
|
|
28
|
+
*/
|
|
29
|
+
export type PoolAction = {
|
|
30
|
+
count: 1;
|
|
31
|
+
type: 'remove_ha';
|
|
32
|
+
} | {
|
|
33
|
+
count: 2;
|
|
34
|
+
type: 'add_ha';
|
|
35
|
+
} | {
|
|
36
|
+
max: number;
|
|
37
|
+
min: number;
|
|
38
|
+
type: 'update_count';
|
|
39
|
+
} | {
|
|
40
|
+
type: 'change_level';
|
|
41
|
+
} | {
|
|
42
|
+
type: 'destroy';
|
|
43
|
+
};
|
|
44
|
+
export type ListAvailableActionsOptions = {
|
|
45
|
+
/**
|
|
46
|
+
* Total instances across *all* follower pools in the cluster, *including*
|
|
47
|
+
* the pool being queried. Used to bound a follower's `update_count` range:
|
|
48
|
+
* the edited pool's own instances are excluded so it can grow back into the
|
|
49
|
+
* budget they occupy (`max = MAX_POOL_INSTANCES - (count - pool.expected_count)`,
|
|
50
|
+
* floored at 1). Required so an omitted count can't silently widen the range
|
|
51
|
+
* to the full budget. Ignored for the leader pool.
|
|
52
|
+
*/
|
|
53
|
+
followerInstanceCount: number;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Derives the selectable compute levels for a pool. Pure — no request.
|
|
57
|
+
*
|
|
58
|
+
* Returns every level from `levels`, marking the pool's current
|
|
59
|
+
* `expected_level` unavailable (`reason: 'current_level'`) so the UI can
|
|
60
|
+
* disable it. With no `pool`, every level is available (e.g. choosing the
|
|
61
|
+
* level for a brand-new pool).
|
|
62
|
+
*/
|
|
63
|
+
export declare function listAvailableLevels(levels: PostgresLevelInfoResult, pool?: Pick<PostgresPoolInfoResult, 'expected_level'>): AvailableLevel[];
|
|
64
|
+
/**
|
|
65
|
+
* Derives the actions available on a pool. Pure — no request.
|
|
66
|
+
*
|
|
67
|
+
* The leader pool can always change level and can toggle high availability:
|
|
68
|
+
* `remove_ha` (down to a single instance) when it currently runs a standby
|
|
69
|
+
* (`expected_count > 1`), otherwise `add_ha` (up to two instances). A follower
|
|
70
|
+
* pool can change level, update its instance count (bounded by the cluster's
|
|
71
|
+
* remaining instance budget), or be destroyed.
|
|
72
|
+
*/
|
|
73
|
+
export declare function listAvailableActions(pool: Pick<PostgresPoolInfoResult, 'expected_count' | 'name'>, options: ListAvailableActionsOptions): PoolAction[];
|
|
74
|
+
export declare const postgresPoolExtensions: import("../../core/extend-resource.js").ResourceExtension<"data", "postgresPool", {
|
|
75
|
+
listAvailableActions: typeof listAvailableActions;
|
|
76
|
+
listAvailableLevels: typeof listAvailableLevels;
|
|
77
|
+
}>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { extendResource } from '../../core/extend-resource.js';
|
|
2
|
+
/** The maximum number of follower-pool instances a database cluster can hold. */
|
|
3
|
+
export const MAX_POOL_INSTANCES = 13;
|
|
4
|
+
/** The name the leader pool always carries; any other name is a follower pool. */
|
|
5
|
+
export const LEADER_POOL_NAME = 'leader';
|
|
6
|
+
/**
|
|
7
|
+
* Derives the selectable compute levels for a pool. Pure — no request.
|
|
8
|
+
*
|
|
9
|
+
* Returns every level from `levels`, marking the pool's current
|
|
10
|
+
* `expected_level` unavailable (`reason: 'current_level'`) so the UI can
|
|
11
|
+
* disable it. With no `pool`, every level is available (e.g. choosing the
|
|
12
|
+
* level for a brand-new pool).
|
|
13
|
+
*/
|
|
14
|
+
export function listAvailableLevels(levels, pool) {
|
|
15
|
+
return levels.items.map(level => level.name === pool?.expected_level
|
|
16
|
+
? { available: false, level, reason: 'current_level' }
|
|
17
|
+
: { available: true, level });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Derives the actions available on a pool. Pure — no request.
|
|
21
|
+
*
|
|
22
|
+
* The leader pool can always change level and can toggle high availability:
|
|
23
|
+
* `remove_ha` (down to a single instance) when it currently runs a standby
|
|
24
|
+
* (`expected_count > 1`), otherwise `add_ha` (up to two instances). A follower
|
|
25
|
+
* pool can change level, update its instance count (bounded by the cluster's
|
|
26
|
+
* remaining instance budget), or be destroyed.
|
|
27
|
+
*/
|
|
28
|
+
export function listAvailableActions(pool, options) {
|
|
29
|
+
if (pool.name === LEADER_POOL_NAME) {
|
|
30
|
+
return [
|
|
31
|
+
{ type: 'change_level' },
|
|
32
|
+
pool.expected_count > 1 ? { count: 1, type: 'remove_ha' } : { count: 2, type: 'add_ha' },
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
const { followerInstanceCount } = options;
|
|
36
|
+
// `followerInstanceCount` includes this pool's own instances; exclude them so
|
|
37
|
+
// the pool can grow back into the budget it already occupies. Floor at 1 so
|
|
38
|
+
// the range never collapses below `min` even if the counts are inconsistent.
|
|
39
|
+
const otherInstances = Math.max(0, followerInstanceCount - pool.expected_count);
|
|
40
|
+
const max = Math.max(1, MAX_POOL_INSTANCES - otherInstances);
|
|
41
|
+
return [
|
|
42
|
+
{ type: 'change_level' },
|
|
43
|
+
{ max, min: 1, type: 'update_count' },
|
|
44
|
+
{ type: 'destroy' },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
export const postgresPoolExtensions = extendResource('data', 'postgresPool', () => ({
|
|
48
|
+
listAvailableActions,
|
|
49
|
+
listAvailableLevels,
|
|
50
|
+
}));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { PostgresQuotaInfoResult, PostgresQuotaListResult, PostgresQuotaUpdateOpts, PostgresQuotaUpdateResult } from '@heroku/types/data';
|
|
2
|
+
import type { ResourceCtx } from '../../core/extend-resource.js';
|
|
3
|
+
import type { ResolvedAddOn } from '../platform/add-on/types.js';
|
|
4
|
+
export type PostgresQuotaOptions = {
|
|
5
|
+
appIdentity?: string;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
};
|
|
8
|
+
export type ListPostgresQuotasResult = {
|
|
9
|
+
addon: ResolvedAddOn;
|
|
10
|
+
quotas: PostgresQuotaListResult;
|
|
11
|
+
};
|
|
12
|
+
export type GetPostgresQuotaResult = {
|
|
13
|
+
addon: ResolvedAddOn;
|
|
14
|
+
quota: PostgresQuotaInfoResult;
|
|
15
|
+
};
|
|
16
|
+
export type UpdatePostgresQuotaResult = {
|
|
17
|
+
addon: ResolvedAddOn;
|
|
18
|
+
quota: PostgresQuotaUpdateResult;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Lists quotas on an Advanced-tier Postgres database.
|
|
22
|
+
*
|
|
23
|
+
* Throws `NotAdvancedDatabaseError` (carrying the resolved addon) if
|
|
24
|
+
* `addonIdentity` doesn't resolve to an Advanced-tier database.
|
|
25
|
+
*/
|
|
26
|
+
export declare function list(ctx: Pick<ResourceCtx, 'data' | 'platform'>, addonIdentity: string, options?: PostgresQuotaOptions): Promise<ListPostgresQuotasResult>;
|
|
27
|
+
/**
|
|
28
|
+
* Looks up a single quota on an Advanced-tier Postgres database.
|
|
29
|
+
*
|
|
30
|
+
* Throws `NotAdvancedDatabaseError` (carrying the resolved addon) if
|
|
31
|
+
* `addonIdentity` doesn't resolve to an Advanced-tier database.
|
|
32
|
+
*/
|
|
33
|
+
export declare function info(ctx: Pick<ResourceCtx, 'data' | 'platform'>, addonIdentity: string, quotaType: string, options?: PostgresQuotaOptions): Promise<GetPostgresQuotaResult>;
|
|
34
|
+
/**
|
|
35
|
+
* Updates a quota's settings on an Advanced-tier Postgres database.
|
|
36
|
+
*
|
|
37
|
+
* Throws `NotAdvancedDatabaseError` (carrying the resolved addon) if
|
|
38
|
+
* `addonIdentity` doesn't resolve to an Advanced-tier database.
|
|
39
|
+
*/
|
|
40
|
+
export declare function update(ctx: Pick<ResourceCtx, 'data' | 'platform'>, addonIdentity: string, quotaType: string, body: PostgresQuotaUpdateOpts, options?: PostgresQuotaOptions): Promise<UpdatePostgresQuotaResult>;
|
|
41
|
+
export declare const postgresQuotaExtensions: import("../../core/extend-resource.js").ResourceExtension<"data", "postgresQuota", {
|
|
42
|
+
info: (addonIdentity: string, quotaType: string, options?: PostgresQuotaOptions) => Promise<GetPostgresQuotaResult>;
|
|
43
|
+
list: (addonIdentity: string, options?: PostgresQuotaOptions) => Promise<ListPostgresQuotasResult>;
|
|
44
|
+
update: (addonIdentity: string, quotaType: string, body: PostgresQuotaUpdateOpts, options?: PostgresQuotaOptions) => Promise<UpdatePostgresQuotaResult>;
|
|
45
|
+
}>;
|