@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.
Files changed (33) hide show
  1. package/dist/resources/data/database-credential/index.d.ts +0 -1
  2. package/dist/resources/data/database-credential/index.js +0 -1
  3. package/dist/resources/data/database-credential/list.d.ts +0 -15
  4. package/dist/resources/data/database-credential/list.js +2 -20
  5. package/dist/resources/data/database-credential/rotate.d.ts +1 -3
  6. package/dist/resources/data/internal/postgres-utils.d.ts +27 -11
  7. package/dist/resources/data/internal/postgres-utils.js +19 -7
  8. package/dist/resources/data/internal/resolve-pg-database.js +2 -2
  9. package/dist/resources/data/postgres-database.d.ts +107 -1
  10. package/dist/resources/data/postgres-database.js +207 -1
  11. package/dist/resources/data/postgres-migration.d.ts +182 -0
  12. package/dist/resources/data/postgres-migration.js +313 -0
  13. package/dist/resources/data/postgres-pool.d.ts +77 -0
  14. package/dist/resources/data/postgres-pool.js +50 -0
  15. package/dist/resources/data/postgres-quota.d.ts +45 -0
  16. package/dist/resources/data/postgres-quota.js +61 -0
  17. package/dist/resources/data/postgres-settings.d.ts +33 -0
  18. package/dist/resources/data/postgres-settings.js +47 -0
  19. package/dist/resources/data/postgres.d.ts +229 -0
  20. package/dist/resources/data/postgres.js +273 -0
  21. package/dist/resources/data/redis/index.d.ts +1 -1
  22. package/dist/resources/extensions/data.d.ts +5 -0
  23. package/dist/resources/extensions/data.js +5 -0
  24. package/dist/resources/platform/add-on/destroy-and-wait.d.ts +2 -3
  25. package/dist/resources/platform/add-on/destroy-and-wait.js +7 -1
  26. package/dist/resources/platform/add-on/errors.d.ts +35 -2
  27. package/dist/resources/platform/add-on/errors.js +52 -0
  28. package/dist/resources/platform/add-on/index.d.ts +3 -3
  29. package/dist/resources/platform/add-on/index.js +1 -1
  30. package/dist/resources/platform/add-on/types.d.ts +14 -1
  31. package/dist/version.d.ts +1 -1
  32. package/dist/version.js +1 -1
  33. 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
+ }>;