@amerged/ohmyhost-cli 0.1.37 → 0.1.39
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/README.md +1 -1
- package/THIRD_PARTY_NOTICES.md +27 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-IHXNADOQ.js → chunk-R2BC42EW.js} +4501 -134
- package/dist/index.js +1 -1
- package/dist/licenses/pgcf-bridge-LICENSE +201 -0
- package/dist/licenses/pgcf-bridge-NOTICE +1 -0
- package/package.json +1 -1
- package/src/apps/product-cli/package.json +4 -3
- package/src/apps/product-cli/src/cli.ts +7 -5
- package/src/apps/product-cli/src/command.ts +18 -5
- package/src/apps/product-cli/src/product-api.ts +22 -15
- package/src/apps/product-cli/src/psql-session.ts +54 -19
- package/src/apps/product-cli/src/repository-init.ts +181 -9
- package/src/packages/contracts/package.json +6 -1
- package/src/packages/contracts/src/credit-pricing.ts +7 -1
- package/src/packages/contracts/src/database-access.ts +53 -12
- package/src/packages/contracts/src/database-compute.ts +66 -11
- package/src/packages/contracts/src/database-sizes.ts +114 -0
- package/src/packages/contracts/src/framework-admission-corpus.ts +15 -18
- package/src/packages/contracts/src/framework-build-script.mjs +10 -3
- package/src/packages/contracts/src/index.ts +3 -0
- package/src/packages/contracts/src/operation-failure.ts +2 -2
- package/src/packages/contracts/src/platform-origins.ts +8 -0
- package/src/packages/contracts/src/problem-policy.ts +16 -2
- package/src/packages/contracts/src/storage-gateway-origin.ts +93 -0
- package/src/packages/postgres-bridge/LICENSE +201 -0
- package/src/packages/postgres-bridge/NOTICE +1 -0
- package/src/packages/postgres-bridge/package.json +26 -0
- package/src/packages/postgres-bridge/src/auth.ts +159 -0
- package/src/packages/postgres-bridge/src/bridge.ts +168 -0
- package/src/packages/postgres-bridge/src/index.ts +2 -0
- package/src/packages/postgres-bridge/src/options.ts +49 -0
- package/src/packages/sdk-ts/package.json +2 -2
- package/src/packages/sdk-ts/src/generated/index.ts +1 -0
- package/src/packages/sdk-ts/src/generated/sdk.gen.ts +9 -8
- package/src/packages/sdk-ts/src/generated/types.gen.ts +91 -24
- package/src/packages/sdk-ts/src/index.ts +1 -0
- package/src/packages/sdk-ts/src/managed-mail-attachment.ts +107 -0
- package/src/packages/workos-auth-contracts/package.json +1 -1
|
@@ -75,6 +75,8 @@ export type RepositoryFramework = Exclude<FrameworkFamilyClassification, "ambigu
|
|
|
75
75
|
const MAXIMUM_SOURCE_BYTES = 2 * 1024 * 1024;
|
|
76
76
|
const SOURCE_MODULE_PATTERN = /\.(?:[cm]?[jt]s|[jt]sx)$/u;
|
|
77
77
|
const STORAGE_CLIENT_CALL_PATTERN = /\bcreatePrivateStorageClient\s*\(/u;
|
|
78
|
+
const GATEWAY_BINDING_NAME = /^OHMYHOST_(?:MAIL|STORAGE)_GATEWAY$/u;
|
|
79
|
+
const GATEWAY_WORD_TOKEN = /[\p{ID_Start}_$][\p{ID_Continue}$]*/uy;
|
|
78
80
|
// The catalog names a module only by a path of its grammar; a file name with a space, for example,
|
|
79
81
|
// is not one, so its blocker carries the path beside a sentence that does not quote it.
|
|
80
82
|
const UNNAMED_MODULE_MESSAGES: Readonly<Record<DatabaseContractCode, string>> = Object.freeze({
|
|
@@ -89,15 +91,19 @@ function callsStorageClient(source: string): boolean {
|
|
|
89
91
|
return STORAGE_CLIENT_CALL_PATTERN.test(sourceWithoutComments(source));
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
+
// Global fetch reaches the private storage gateway using these values and its environment key.
|
|
95
|
+
// No raw bucket binding is handed to customer code.
|
|
94
96
|
const STORAGE_RUNTIME_BINDINGS = Object.freeze([
|
|
95
97
|
"OHMYHOST_ENVIRONMENT_ID",
|
|
96
98
|
"OHMYHOST_PROJECT_ID",
|
|
97
|
-
"OHMYHOST_STORAGE_GATEWAY",
|
|
98
99
|
"OHMYHOST_STORAGE_GATEWAY_URL",
|
|
99
100
|
"OHMYHOST_STORAGE_KEY",
|
|
100
101
|
]);
|
|
102
|
+
const MAIL_RUNTIME_BINDINGS = Object.freeze([
|
|
103
|
+
"OHMYHOST_MAIL_GATEWAY_URL",
|
|
104
|
+
"OHMYHOST_MAIL_KEY",
|
|
105
|
+
"OHMYHOST_PROJECT_ID",
|
|
106
|
+
]);
|
|
101
107
|
// The runtime release is published unchanged as `@amerged/ohmyhost-runtime`; this pinned alias keeps
|
|
102
108
|
// its import name and, unlike the website archive each release removes, stays installable.
|
|
103
109
|
const CUSTOMER_RUNTIME_SOURCE = `npm:@amerged/ohmyhost-runtime@${cliPackage.version}`;
|
|
@@ -105,6 +111,7 @@ const CUSTOMER_RUNTIME_SOURCE = `npm:@amerged/ohmyhost-runtime@${cliPackage.vers
|
|
|
105
111
|
/** A catalog code, which planning answers with the same sentence, or a check only init runs. */
|
|
106
112
|
export type RepositoryInitBlockerCode =
|
|
107
113
|
| FrameworkConversionDiagnosticCode
|
|
114
|
+
| "gateway_binding_unsupported"
|
|
108
115
|
| "migration_filename_noncanonical"
|
|
109
116
|
| "migration_sql_not_admitted"
|
|
110
117
|
| "storage_client_missing";
|
|
@@ -507,7 +514,7 @@ interface SourceCheckContext {
|
|
|
507
514
|
/**
|
|
508
515
|
* Every blocker of a source with a valid or proposed configuration, in the order planning judges:
|
|
509
516
|
* migration filenames, the shared source rules, the Worker module handlers and the database
|
|
510
|
-
* contract; init's own storage client and migration SQL checks come last.
|
|
517
|
+
* contract; init's own gateway binding, storage client and migration SQL checks come last.
|
|
511
518
|
*/
|
|
512
519
|
async function judgeSource(
|
|
513
520
|
context: SourceCheckContext &
|
|
@@ -528,6 +535,7 @@ async function judgeSource(
|
|
|
528
535
|
...source.findings.map((finding) => requiredCatalogBlocker(finding, context.applicationRoot)),
|
|
529
536
|
...(await workerModuleBlockers(context)),
|
|
530
537
|
...(await databaseContractBlockers(context, context.provider)),
|
|
538
|
+
...(await gatewayBindingBlockers(context)),
|
|
531
539
|
...(await storageClientBlockers(context)),
|
|
532
540
|
...(await migrationSqlBlockers(context, context.provider)),
|
|
533
541
|
],
|
|
@@ -621,6 +629,168 @@ async function workerModuleBlockers(context: SourceCheckContext): Promise<Reposi
|
|
|
621
629
|
);
|
|
622
630
|
}
|
|
623
631
|
|
|
632
|
+
/** Keep literal text inert, but scan executable template interpolations as ordinary code. */
|
|
633
|
+
function gatewaySourceTokens(source: string): readonly string[] {
|
|
634
|
+
const tokens: string[] = [];
|
|
635
|
+
let cursor = 0;
|
|
636
|
+
const quoted = (quote: string) => {
|
|
637
|
+
const start = cursor++;
|
|
638
|
+
while (cursor < source.length) {
|
|
639
|
+
const character = source[cursor++];
|
|
640
|
+
if (character === "\\") cursor++;
|
|
641
|
+
else if (character === quote) break;
|
|
642
|
+
}
|
|
643
|
+
tokens.push(source.slice(start, cursor));
|
|
644
|
+
};
|
|
645
|
+
const code = (interpolation: boolean): void => {
|
|
646
|
+
let braces = 0;
|
|
647
|
+
while (cursor < source.length) {
|
|
648
|
+
const character = source[cursor] as string;
|
|
649
|
+
if (interpolation && character === "}" && braces === 0) {
|
|
650
|
+
cursor++;
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (character === '"' || character === "'") {
|
|
654
|
+
quoted(character);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (character === "`") {
|
|
658
|
+
cursor++;
|
|
659
|
+
while (cursor < source.length) {
|
|
660
|
+
const literal = source[cursor++];
|
|
661
|
+
if (literal === "\\") cursor++;
|
|
662
|
+
else if (literal === "`") break;
|
|
663
|
+
else if (literal === "$" && source[cursor] === "{") {
|
|
664
|
+
cursor++;
|
|
665
|
+
tokens.push("(");
|
|
666
|
+
code(true);
|
|
667
|
+
tokens.push(")");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (/\s/u.test(character)) {
|
|
673
|
+
cursor++;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (character === "{") braces++;
|
|
677
|
+
if (character === "}") braces--;
|
|
678
|
+
GATEWAY_WORD_TOKEN.lastIndex = cursor;
|
|
679
|
+
const word = GATEWAY_WORD_TOKEN.exec(source)?.[0];
|
|
680
|
+
const token =
|
|
681
|
+
word ??
|
|
682
|
+
(["?.", "=>"].includes(source.slice(cursor, cursor + 2))
|
|
683
|
+
? source.slice(cursor, cursor + 2)
|
|
684
|
+
: character);
|
|
685
|
+
tokens.push(token);
|
|
686
|
+
cursor += token.length;
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
code(false);
|
|
690
|
+
return tokens;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Static value accesses only: declarations, literal messages and object-literal keys are inert. */
|
|
694
|
+
function usesGatewayBinding(source: string): boolean {
|
|
695
|
+
const tokens = gatewaySourceTokens(sourceWithoutComments(source));
|
|
696
|
+
const objects: boolean[] = [];
|
|
697
|
+
let declaration: "type" | "interface" | "annotation" | undefined;
|
|
698
|
+
let declarationDepth = 0;
|
|
699
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
700
|
+
const token = tokens[index] as string;
|
|
701
|
+
const previous = tokens[index - 1];
|
|
702
|
+
const next = tokens[index + 1];
|
|
703
|
+
if (declaration !== undefined) {
|
|
704
|
+
if (
|
|
705
|
+
declarationDepth === 0 &&
|
|
706
|
+
(token === ";" || /^(?:export|const|let|var|function|class|type|interface)$/u.test(token))
|
|
707
|
+
) {
|
|
708
|
+
declaration = undefined;
|
|
709
|
+
if (token === ";") continue;
|
|
710
|
+
} else {
|
|
711
|
+
if (token === "{" || token === "(" || token === "[") declarationDepth++;
|
|
712
|
+
if (token === "}" || token === ")" || token === "]") {
|
|
713
|
+
declarationDepth--;
|
|
714
|
+
if (declaration !== "type" && declarationDepth === 0) declaration = undefined;
|
|
715
|
+
}
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (
|
|
720
|
+
(token === "type" || token === "interface") &&
|
|
721
|
+
next !== undefined &&
|
|
722
|
+
/^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u.test(next) &&
|
|
723
|
+
previous !== "." &&
|
|
724
|
+
previous !== "?."
|
|
725
|
+
) {
|
|
726
|
+
declaration = token;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
// An inline variable type is not an object binding pattern; resume at its initializer.
|
|
730
|
+
if (token === "{" && previous === ":" && /^(?:const|let|var)$/u.test(tokens[index - 3] ?? "")) {
|
|
731
|
+
declaration = "annotation";
|
|
732
|
+
declarationDepth = 1;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (token === "{") objects.push(false);
|
|
736
|
+
if (token === "}") {
|
|
737
|
+
const gateway = objects.pop();
|
|
738
|
+
let after = index + 1;
|
|
739
|
+
if (tokens[after] === ":") {
|
|
740
|
+
after++;
|
|
741
|
+
while (after < tokens.length && !/^(?:=|;|\{|\})$/u.test(tokens[after] as string)) after++;
|
|
742
|
+
}
|
|
743
|
+
if (gateway && tokens[after] === "=") return true;
|
|
744
|
+
}
|
|
745
|
+
const quoted = token.startsWith('"') || token.startsWith("'");
|
|
746
|
+
const name = quoted ? token.slice(1, -1) : token;
|
|
747
|
+
if (!GATEWAY_BINDING_NAME.test(name)) continue;
|
|
748
|
+
if (!quoted && (previous === "." || previous === "?.")) return true;
|
|
749
|
+
const receiver = tokens[index - (tokens[index - 2] === "?." ? 3 : 2)] ?? "";
|
|
750
|
+
if (
|
|
751
|
+
quoted &&
|
|
752
|
+
previous === "[" &&
|
|
753
|
+
next === "]" &&
|
|
754
|
+
(/^[\p{ID_Start}_$][\p{ID_Continue}$]*$/u.test(receiver) ||
|
|
755
|
+
receiver === ")" ||
|
|
756
|
+
receiver === "]") &&
|
|
757
|
+
!/^(?:return|throw|yield|case|new|typeof|void|delete|await|in|of)$/u.test(receiver)
|
|
758
|
+
)
|
|
759
|
+
return true;
|
|
760
|
+
if (
|
|
761
|
+
objects.length > 0 &&
|
|
762
|
+
(previous === "{" || previous === ",") &&
|
|
763
|
+
(next === ":" || next === "," || next === "}" || next === "=")
|
|
764
|
+
)
|
|
765
|
+
objects[objects.length - 1] = true;
|
|
766
|
+
}
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Init names legacy mail/storage bindings before a customer builds code whose gateway calls the
|
|
772
|
+
* egress policy cannot serve. This local guidance does not change planning's admission policy.
|
|
773
|
+
*/
|
|
774
|
+
async function gatewayBindingBlockers(
|
|
775
|
+
context: SourceCheckContext,
|
|
776
|
+
): Promise<RepositoryInitBlocker[]> {
|
|
777
|
+
const blockers: RepositoryInitBlocker[] = [];
|
|
778
|
+
for (const path of context.files) {
|
|
779
|
+
if (!SOURCE_MODULE_PATTERN.test(path) || /\.d\.[cm]?ts$/u.test(path)) continue;
|
|
780
|
+
const absolute = resolve(context.applicationDirectory, path);
|
|
781
|
+
const information = await stat(absolute);
|
|
782
|
+
if (!information.isFile() || information.size > MAXIMUM_SOURCE_BYTES) continue;
|
|
783
|
+
if (!usesGatewayBinding(await readFile(absolute, "utf8"))) continue;
|
|
784
|
+
blockers.push({
|
|
785
|
+
code: "gateway_binding_unsupported",
|
|
786
|
+
path: repositoryPath(context.applicationRoot, path),
|
|
787
|
+
message:
|
|
788
|
+
"Mail and storage gateway Service Bindings are unsupported. In server code, create the client from its OHMYHOST_MAIL_GATEWAY_URL or OHMYHOST_STORAGE_GATEWAY_URL, environment key and project ID, plus the environment ID for storage. Use the global fetch with fetch: (request) => fetch(request), and capabilityFetch: (request) => fetch(request) for storage signed URLs.",
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
return blockers;
|
|
792
|
+
}
|
|
793
|
+
|
|
624
794
|
/**
|
|
625
795
|
* Declared storage is reached only through the private gateway client. A storage-enabled app whose
|
|
626
796
|
* source never constructs that client deploys green and fails at its first upload, so name it here.
|
|
@@ -641,7 +811,7 @@ async function storageClientBlockers(
|
|
|
641
811
|
code: "storage_client_missing",
|
|
642
812
|
path: null,
|
|
643
813
|
message:
|
|
644
|
-
'ohmyhost.yaml declares storage, but no application source calls createPrivateStorageClient. Import it from "@ohmyhost/customer-runtime/storage" and build it in server code from
|
|
814
|
+
'ohmyhost.yaml declares storage, but no application source calls createPrivateStorageClient. Import it from "@ohmyhost/customer-runtime/storage" and build it in server code from OHMYHOST_STORAGE_GATEWAY_URL, OHMYHOST_PROJECT_ID, OHMYHOST_ENVIRONMENT_ID and OHMYHOST_STORAGE_KEY, with the global fetch as fetch: (request) => fetch(request) and capabilityFetch: (request) => fetch(request). Reserve an upload, PUT the bytes to the returned signed URL, complete it, and serve reads with createSignedRead. There is no FILES bucket binding.',
|
|
645
815
|
},
|
|
646
816
|
];
|
|
647
817
|
}
|
|
@@ -1418,10 +1588,12 @@ function runtimeContract(capabilities: RuntimeCapabilities): Readonly<{
|
|
|
1418
1588
|
return Object.freeze({
|
|
1419
1589
|
bindings: Object.freeze(
|
|
1420
1590
|
[
|
|
1421
|
-
...(
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1591
|
+
...new Set([
|
|
1592
|
+
...(capabilities.auth ? ["BETTER_AUTH_SECRET"] : []),
|
|
1593
|
+
...(capabilities.storage ? STORAGE_RUNTIME_BINDINGS : []),
|
|
1594
|
+
...(capabilities.database ? ["OHMYHOST_DATABASE"] : []),
|
|
1595
|
+
...(capabilities.mail ? MAIL_RUNTIME_BINDINGS : []),
|
|
1596
|
+
]),
|
|
1425
1597
|
].sort(),
|
|
1426
1598
|
),
|
|
1427
1599
|
packages: Object.freeze({
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"exports": {
|
|
8
|
+
"./database-sizes": {
|
|
9
|
+
"source": "./src/database-sizes.ts",
|
|
10
|
+
"types": "./dist/database-sizes.d.ts",
|
|
11
|
+
"import": "./dist/database-sizes.js"
|
|
12
|
+
},
|
|
8
13
|
"./shared-mail": {
|
|
9
14
|
"source": "./src/shared-mail.ts",
|
|
10
15
|
"types": "./dist/shared-mail.d.ts",
|
|
@@ -150,7 +155,7 @@
|
|
|
150
155
|
},
|
|
151
156
|
"scripts": {
|
|
152
157
|
"build": "tsc --project tsconfig.build.json",
|
|
153
|
-
"typecheck": "tsc --project tsconfig.json --noEmit",
|
|
158
|
+
"typecheck": "tsc --project tsconfig.json --noEmit --incremental --tsBuildInfoFile typecheck.tsbuildinfo",
|
|
154
159
|
"test": "vitest run --root ../.. packages/contracts/test",
|
|
155
160
|
"validate": "pnpm run typecheck && pnpm run test"
|
|
156
161
|
},
|
|
@@ -96,7 +96,7 @@ export interface PublishedCreditRateCard {
|
|
|
96
96
|
readonly meter: string;
|
|
97
97
|
readonly unit: string;
|
|
98
98
|
readonly units_per_charge: string;
|
|
99
|
-
readonly provider_cost_micros: string;
|
|
99
|
+
readonly provider_cost_micros: string | null;
|
|
100
100
|
readonly credit_micros: string;
|
|
101
101
|
}[];
|
|
102
102
|
}
|
|
@@ -159,6 +159,12 @@ export function assertPublishedCreditRateCards(
|
|
|
159
159
|
meters.add(identity);
|
|
160
160
|
for (const key of ["units_per_charge", "provider_cost_micros", "credit_micros"]) {
|
|
161
161
|
const amount = rate[key];
|
|
162
|
+
if (
|
|
163
|
+
key === "provider_cost_micros" &&
|
|
164
|
+
amount === null &&
|
|
165
|
+
rate["meter"] === "pgcf.memory.active"
|
|
166
|
+
)
|
|
167
|
+
continue;
|
|
162
168
|
if (
|
|
163
169
|
typeof amount !== "string" ||
|
|
164
170
|
!/^(0|[1-9][0-9]{0,18})$/u.test(amount) ||
|
|
@@ -93,13 +93,12 @@ export interface ProjectDatabaseAccessRequest {
|
|
|
93
93
|
readonly ttl_seconds: number;
|
|
94
94
|
readonly label: string | null;
|
|
95
95
|
}
|
|
96
|
-
export interface
|
|
96
|
+
export interface ProjectDatabaseAccessFields {
|
|
97
97
|
readonly access_id: string;
|
|
98
98
|
readonly environment: DatabaseEnvironment;
|
|
99
99
|
readonly mode: DatabaseAccessMode;
|
|
100
100
|
readonly role_name: string;
|
|
101
101
|
readonly host: string;
|
|
102
|
-
readonly database: "neondb";
|
|
103
102
|
readonly label: string | null;
|
|
104
103
|
readonly state: "active" | "expired" | "revoked";
|
|
105
104
|
readonly issued_at: string;
|
|
@@ -113,10 +112,25 @@ export interface ProjectDatabaseAccess {
|
|
|
113
112
|
| "data_change"
|
|
114
113
|
| null;
|
|
115
114
|
}
|
|
116
|
-
export
|
|
115
|
+
export type ProjectDatabaseAccess = ProjectDatabaseAccessFields &
|
|
116
|
+
(
|
|
117
|
+
| {
|
|
118
|
+
readonly database: "neondb";
|
|
119
|
+
readonly provider?: never;
|
|
120
|
+
readonly transport?: never;
|
|
121
|
+
readonly websocket_url?: never;
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
readonly provider: "pgcf";
|
|
125
|
+
readonly transport: "wss";
|
|
126
|
+
readonly websocket_url: string;
|
|
127
|
+
readonly database: string;
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
export type ProjectDatabaseAccessCredential = ProjectDatabaseAccess & {
|
|
117
131
|
readonly connection_uri: string;
|
|
118
132
|
readonly psql_command: string;
|
|
119
|
-
}
|
|
133
|
+
};
|
|
120
134
|
export interface ProjectDatabaseAccessPage {
|
|
121
135
|
readonly items: readonly ProjectDatabaseAccess[];
|
|
122
136
|
}
|
|
@@ -163,17 +177,27 @@ export function parseProjectDatabaseAccessRequest(value: unknown): ProjectDataba
|
|
|
163
177
|
export function assertProjectDatabaseAccess(
|
|
164
178
|
value: unknown,
|
|
165
179
|
): asserts value is ProjectDatabaseAccess {
|
|
166
|
-
if (!record(value) || !exact(value,
|
|
180
|
+
if (!record(value) || !exact(value, accessKeys(value)))
|
|
181
|
+
throw new TypeError("Invalid database access");
|
|
167
182
|
assertAccessFields(value);
|
|
168
183
|
}
|
|
169
184
|
export function assertProjectDatabaseAccessCredential(
|
|
170
185
|
value: unknown,
|
|
171
186
|
): asserts value is ProjectDatabaseAccessCredential {
|
|
172
|
-
if (!record(value) || !exact(value, [...
|
|
187
|
+
if (!record(value) || !exact(value, [...accessKeys(value), "connection_uri", "psql_command"]))
|
|
173
188
|
throw new TypeError("Invalid database access credential");
|
|
174
189
|
assertAccessFields(value);
|
|
175
190
|
const uri = value["connection_uri"];
|
|
176
|
-
if (
|
|
191
|
+
if (
|
|
192
|
+
typeof uri !== "string" ||
|
|
193
|
+
(value["provider"] === "pgcf"
|
|
194
|
+
? typeof value["psql_command"] !== "string" ||
|
|
195
|
+
!new RegExp(
|
|
196
|
+
`^ohmyhost database psql --project [0-7][0-9A-HJKMNP-TV-Z]{25} --environment ${value["environment"]} --mode ${value["mode"]}$`,
|
|
197
|
+
"u",
|
|
198
|
+
).test(value["psql_command"])
|
|
199
|
+
: value["psql_command"] !== `psql '${uri}'`)
|
|
200
|
+
)
|
|
177
201
|
throw new TypeError("Invalid database access credential");
|
|
178
202
|
let parsed: URL;
|
|
179
203
|
try {
|
|
@@ -182,13 +206,20 @@ export function assertProjectDatabaseAccessCredential(
|
|
|
182
206
|
throw new TypeError("Invalid database access credential");
|
|
183
207
|
}
|
|
184
208
|
if (
|
|
185
|
-
|
|
209
|
+
(value["provider"] === "pgcf"
|
|
210
|
+
? !["postgres:", "postgresql:"].includes(parsed.protocol)
|
|
211
|
+
: parsed.protocol !== "postgresql:") ||
|
|
186
212
|
decodeURIComponent(parsed.username) !== value["role_name"] ||
|
|
187
213
|
parsed.password === "" ||
|
|
188
214
|
parsed.hostname !== value["host"] ||
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
215
|
+
(value["provider"] === "pgcf"
|
|
216
|
+
? parsed.port !== "" && parsed.port !== "5432"
|
|
217
|
+
: parsed.host !== value["host"]) ||
|
|
218
|
+
decodeURIComponent(parsed.pathname.slice(1)) !== value["database"] ||
|
|
219
|
+
parsed.hash !== "" ||
|
|
220
|
+
(value["provider"] === "pgcf"
|
|
221
|
+
? !["", "?sslmode=require", "?sslmode=verify-full"].includes(parsed.search)
|
|
222
|
+
: parsed.search !== "?sslmode=require")
|
|
192
223
|
)
|
|
193
224
|
throw new TypeError("Invalid database access credential");
|
|
194
225
|
}
|
|
@@ -204,6 +235,11 @@ export function assertProjectDatabaseAccessPage(
|
|
|
204
235
|
throw new TypeError("Invalid database access page");
|
|
205
236
|
for (const item of value["items"]) assertProjectDatabaseAccess(item);
|
|
206
237
|
}
|
|
238
|
+
function accessKeys(value: Record<string, unknown>): readonly string[] {
|
|
239
|
+
return value["provider"] === "pgcf"
|
|
240
|
+
? [...ACCESS_KEYS, "provider", "transport", "websocket_url"]
|
|
241
|
+
: ACCESS_KEYS;
|
|
242
|
+
}
|
|
207
243
|
function assertAccessFields(value: Record<string, unknown>): void {
|
|
208
244
|
const revoked = value["revoked_at"],
|
|
209
245
|
reason = value["revocation_reason"];
|
|
@@ -216,7 +252,12 @@ function assertAccessFields(value: Record<string, unknown>): void {
|
|
|
216
252
|
!ACCESS_ROLE_PATTERN.test(value["role_name"]) ||
|
|
217
253
|
typeof value["host"] !== "string" ||
|
|
218
254
|
!/^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/u.test(value["host"]) ||
|
|
219
|
-
value["
|
|
255
|
+
(value["provider"] === "pgcf"
|
|
256
|
+
? typeof value["database"] !== "string" ||
|
|
257
|
+
!/^[a-z][a-z0-9]{19}$/u.test(value["database"]) ||
|
|
258
|
+
value["transport"] !== "wss" ||
|
|
259
|
+
value["websocket_url"] !== `wss://${value["host"]}/`
|
|
260
|
+
: value["database"] !== "neondb") ||
|
|
220
261
|
(value["label"] !== null && accessLabel(value["label"]) !== value["label"]) ||
|
|
221
262
|
!["active", "expired", "revoked"].includes(String(value["state"])) ||
|
|
222
263
|
!timestamp(value["issued_at"]) ||
|
|
@@ -1,19 +1,39 @@
|
|
|
1
|
+
import { databaseSizeForClass } from "./database-sizes.js";
|
|
2
|
+
|
|
3
|
+
export type PgcfComputeState = "provisioning" | "cold" | "warm" | "suspended" | "error";
|
|
4
|
+
export interface PgcfDatabaseCompute {
|
|
5
|
+
readonly provider: "pgcf";
|
|
6
|
+
readonly size_class_id: string;
|
|
7
|
+
readonly memory_mib: number;
|
|
8
|
+
readonly cpu_millicores: number;
|
|
9
|
+
readonly storage_mib: number;
|
|
10
|
+
readonly max_connections: number;
|
|
11
|
+
readonly suspend_timeout_seconds: number;
|
|
12
|
+
readonly state: PgcfComputeState;
|
|
13
|
+
readonly pending_state: PgcfComputeState | null;
|
|
14
|
+
readonly disabled: boolean;
|
|
15
|
+
readonly region: "us" | "eu";
|
|
16
|
+
}
|
|
17
|
+
|
|
1
18
|
export interface DatabaseCompute {
|
|
2
19
|
readonly project_id: string;
|
|
3
20
|
readonly environment: "dev" | "prod";
|
|
4
21
|
readonly data_mode: "shared" | "isolated";
|
|
5
22
|
readonly observed_at: string;
|
|
6
|
-
readonly database:
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
readonly database:
|
|
24
|
+
| null
|
|
25
|
+
| PgcfDatabaseCompute
|
|
26
|
+
| {
|
|
27
|
+
readonly min_cu: number;
|
|
28
|
+
readonly max_cu: number;
|
|
29
|
+
readonly min_memory_gb: number;
|
|
30
|
+
readonly max_memory_gb: number;
|
|
31
|
+
readonly suspend_timeout_seconds: number;
|
|
32
|
+
readonly state: "init" | "active" | "idle";
|
|
33
|
+
readonly pending_state: "init" | "active" | "idle" | null;
|
|
34
|
+
readonly disabled: boolean;
|
|
35
|
+
readonly region: string;
|
|
36
|
+
};
|
|
17
37
|
}
|
|
18
38
|
export function assertDatabaseCompute(value: unknown): asserts value is DatabaseCompute {
|
|
19
39
|
if (
|
|
@@ -30,6 +50,10 @@ export function assertDatabaseCompute(value: unknown): asserts value is Database
|
|
|
30
50
|
throw new TypeError("Invalid database compute report");
|
|
31
51
|
const db = value["database"];
|
|
32
52
|
if (db === null) return;
|
|
53
|
+
if (record(db) && db["provider"] === "pgcf") {
|
|
54
|
+
assertPgcfCompute(db);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
33
57
|
if (
|
|
34
58
|
!record(db) ||
|
|
35
59
|
!exact(db, [
|
|
@@ -64,6 +88,37 @@ export function assertDatabaseCompute(value: unknown): asserts value is Database
|
|
|
64
88
|
)
|
|
65
89
|
throw new TypeError("Invalid database compute configuration");
|
|
66
90
|
}
|
|
91
|
+
function assertPgcfCompute(db: Record<string, unknown>): void {
|
|
92
|
+
const size = databaseSizeForClass(db["size_class_id"]);
|
|
93
|
+
const states = ["provisioning", "cold", "warm", "suspended", "error"];
|
|
94
|
+
if (
|
|
95
|
+
!exact(db, [
|
|
96
|
+
"cpu_millicores",
|
|
97
|
+
"disabled",
|
|
98
|
+
"max_connections",
|
|
99
|
+
"memory_mib",
|
|
100
|
+
"pending_state",
|
|
101
|
+
"provider",
|
|
102
|
+
"region",
|
|
103
|
+
"size_class_id",
|
|
104
|
+
"state",
|
|
105
|
+
"storage_mib",
|
|
106
|
+
"suspend_timeout_seconds",
|
|
107
|
+
]) ||
|
|
108
|
+
db["memory_mib"] !== size.memoryMiB ||
|
|
109
|
+
db["cpu_millicores"] !== size.cpuMillicores ||
|
|
110
|
+
db["storage_mib"] !== size.storageMiB ||
|
|
111
|
+
db["max_connections"] !== size.maxConnections ||
|
|
112
|
+
!Number.isSafeInteger(db["suspend_timeout_seconds"]) ||
|
|
113
|
+
Number(db["suspend_timeout_seconds"]) < 60 ||
|
|
114
|
+
Number(db["suspend_timeout_seconds"]) > 900 ||
|
|
115
|
+
!states.includes(String(db["state"])) ||
|
|
116
|
+
(db["pending_state"] !== null && !states.includes(String(db["pending_state"]))) ||
|
|
117
|
+
typeof db["disabled"] !== "boolean" ||
|
|
118
|
+
!["us", "eu"].includes(String(db["region"]))
|
|
119
|
+
)
|
|
120
|
+
throw new TypeError("Invalid fixed database compute configuration");
|
|
121
|
+
}
|
|
67
122
|
function record(v: unknown): v is Record<string, unknown> {
|
|
68
123
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
69
124
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export type DatabaseMemoryMiB = 256 | 512 | 1024 | 2048 | 4096;
|
|
2
|
+
export type DatabaseComputeSelection =
|
|
3
|
+
| { readonly memoryMiB: DatabaseMemoryMiB; readonly profile?: never }
|
|
4
|
+
| { readonly profile: "standard" | "performance"; readonly memoryMiB?: never };
|
|
5
|
+
|
|
6
|
+
export function parseDatabaseComputeChangeRequest(value: unknown): {
|
|
7
|
+
readonly environment: "dev" | "prod";
|
|
8
|
+
readonly selection: DatabaseComputeSelection;
|
|
9
|
+
} {
|
|
10
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
11
|
+
throw new TypeError("Invalid database compute change");
|
|
12
|
+
const input = value as Record<string, unknown>;
|
|
13
|
+
const keys = Object.keys(input).sort().join(",");
|
|
14
|
+
if (
|
|
15
|
+
(keys !== "confirm,environment,memory_mib" && keys !== "confirm,environment,profile") ||
|
|
16
|
+
input["confirm"] !== true ||
|
|
17
|
+
(input["environment"] !== "dev" && input["environment"] !== "prod")
|
|
18
|
+
)
|
|
19
|
+
throw new TypeError("Invalid database compute change");
|
|
20
|
+
if (keys === "confirm,environment,memory_mib")
|
|
21
|
+
return {
|
|
22
|
+
environment: input["environment"],
|
|
23
|
+
selection: { memoryMiB: databaseSizeForMemory(input["memory_mib"]).memoryMiB },
|
|
24
|
+
};
|
|
25
|
+
if (input["profile"] !== "standard" && input["profile"] !== "performance")
|
|
26
|
+
throw new TypeError("Invalid transitional database profile");
|
|
27
|
+
return { environment: input["environment"], selection: { profile: input["profile"] } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DatabaseSize {
|
|
31
|
+
readonly memoryMiB: DatabaseMemoryMiB;
|
|
32
|
+
readonly storageMiB: number;
|
|
33
|
+
readonly cpuMillicores: number;
|
|
34
|
+
readonly maxConnections: number;
|
|
35
|
+
readonly sizeClassId: string;
|
|
36
|
+
readonly creditsPerAwakeHour: number;
|
|
37
|
+
readonly paidRequired: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Operator-approved product sizes; storage is total nominal volume, including WAL. */
|
|
41
|
+
export const DATABASE_SIZES: readonly DatabaseSize[] = Object.freeze([
|
|
42
|
+
Object.freeze({
|
|
43
|
+
memoryMiB: 256,
|
|
44
|
+
storageMiB: 500,
|
|
45
|
+
cpuMillicores: 250,
|
|
46
|
+
maxConnections: 20,
|
|
47
|
+
sizeClassId: "r256-s500m",
|
|
48
|
+
creditsPerAwakeHour: 10,
|
|
49
|
+
paidRequired: false,
|
|
50
|
+
} as const),
|
|
51
|
+
Object.freeze({
|
|
52
|
+
memoryMiB: 512,
|
|
53
|
+
storageMiB: 1024,
|
|
54
|
+
cpuMillicores: 250,
|
|
55
|
+
maxConnections: 40,
|
|
56
|
+
sizeClassId: "r512-s1g",
|
|
57
|
+
creditsPerAwakeHour: 20,
|
|
58
|
+
paidRequired: true,
|
|
59
|
+
} as const),
|
|
60
|
+
Object.freeze({
|
|
61
|
+
memoryMiB: 1024,
|
|
62
|
+
storageMiB: 2048,
|
|
63
|
+
cpuMillicores: 500,
|
|
64
|
+
maxConnections: 80,
|
|
65
|
+
sizeClassId: "r1024-s2g",
|
|
66
|
+
creditsPerAwakeHour: 40,
|
|
67
|
+
paidRequired: true,
|
|
68
|
+
} as const),
|
|
69
|
+
Object.freeze({
|
|
70
|
+
memoryMiB: 2048,
|
|
71
|
+
storageMiB: 4096,
|
|
72
|
+
cpuMillicores: 1000,
|
|
73
|
+
maxConnections: 160,
|
|
74
|
+
sizeClassId: "r2048-s4g",
|
|
75
|
+
creditsPerAwakeHour: 80,
|
|
76
|
+
paidRequired: true,
|
|
77
|
+
} as const),
|
|
78
|
+
Object.freeze({
|
|
79
|
+
memoryMiB: 4096,
|
|
80
|
+
storageMiB: 8192,
|
|
81
|
+
cpuMillicores: 2000,
|
|
82
|
+
maxConnections: 200,
|
|
83
|
+
sizeClassId: "r4096-s8g",
|
|
84
|
+
creditsPerAwakeHour: 160,
|
|
85
|
+
paidRequired: true,
|
|
86
|
+
} as const),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
export function databaseSizeForMemory(value: unknown): DatabaseSize {
|
|
90
|
+
const size = DATABASE_SIZES.find((candidate) => candidate.memoryMiB === value);
|
|
91
|
+
if (size === undefined) throw new TypeError("Unsupported database memory size");
|
|
92
|
+
return size;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function databaseSizeForClass(value: unknown): DatabaseSize {
|
|
96
|
+
const size = DATABASE_SIZES.find((candidate) => candidate.sizeClassId === value);
|
|
97
|
+
if (size === undefined) throw new TypeError("Unsupported database size class");
|
|
98
|
+
return size;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A downgrade never silently reduces a database's physical allocation. */
|
|
102
|
+
export function assertDatabaseSizeChange(input: {
|
|
103
|
+
readonly memoryMiB: unknown;
|
|
104
|
+
readonly paid: boolean;
|
|
105
|
+
readonly currentStorageMiB: number;
|
|
106
|
+
}): DatabaseSize {
|
|
107
|
+
const size = databaseSizeForMemory(input.memoryMiB);
|
|
108
|
+
if (size.paidRequired && !input.paid) throw new TypeError("Database size requires Paid");
|
|
109
|
+
if (!Number.isSafeInteger(input.currentStorageMiB) || input.currentStorageMiB < 0)
|
|
110
|
+
throw new TypeError("Invalid current database allocation");
|
|
111
|
+
if (size.storageMiB < input.currentStorageMiB)
|
|
112
|
+
throw new TypeError("A smaller database volume requires a separate data migration");
|
|
113
|
+
return size;
|
|
114
|
+
}
|