@amerged/ohmyhost-cli 0.1.44 → 0.1.46
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/dist/bin.js +1 -1
- package/dist/{chunk-VJWPDFZ5.js → chunk-A4ZYW4XW.js} +870 -62
- package/dist/index.js +3 -1
- package/package.json +1 -1
- package/src/apps/product-cli/package.json +1 -1
- package/src/apps/product-cli/src/cli.ts +137 -2
- package/src/apps/product-cli/src/command.ts +186 -2
- package/src/apps/product-cli/src/product-api.ts +202 -2
- package/src/packages/contracts/package.json +5 -0
- package/src/packages/contracts/src/database-events.ts +208 -11
- package/src/packages/contracts/src/database-migration-admission.ts +22 -0
- package/src/packages/contracts/src/database-migration-capture.ts +177 -0
- package/src/packages/contracts/src/database-migration-lexical.ts +1 -0
- package/src/packages/contracts/src/database-tables.ts +317 -0
- package/src/packages/contracts/src/problem-policy.ts +50 -1
- package/src/packages/sdk-ts/package.json +1 -1
- package/src/packages/sdk-ts/src/generated/index.ts +37 -0
- package/src/packages/sdk-ts/src/generated/sdk.gen.ts +204 -0
- package/src/packages/sdk-ts/src/generated/types.gen.ts +407 -4
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DATABASE_WATCH_MAX_COLUMNS,
|
|
3
|
+
DATABASE_WATCH_OPERATIONS,
|
|
4
|
+
isDatabaseWatchIdentifier,
|
|
5
|
+
isDatabaseWatchSchema,
|
|
6
|
+
type DatabaseWatchOperation,
|
|
7
|
+
} from "./database-events.js";
|
|
8
|
+
import { DatabaseMigrationAdmissionError } from "./database-migration-errors.js";
|
|
9
|
+
import { isSqlKeyword, type MigrationSqlToken } from "./database-migration-lexical.js";
|
|
10
|
+
|
|
11
|
+
export interface DatabaseMigrationCaptureTrigger {
|
|
12
|
+
readonly trigger: string;
|
|
13
|
+
readonly schema: string;
|
|
14
|
+
readonly table: string;
|
|
15
|
+
readonly operations: readonly DatabaseWatchOperation[];
|
|
16
|
+
readonly columns: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MigrationCaptureStatement {
|
|
20
|
+
readonly target: DatabaseMigrationCaptureTrigger;
|
|
21
|
+
/** The tokens before the pgcf.capture function name, which still pass the ordinary references check. */
|
|
22
|
+
readonly header: readonly MigrationSqlToken[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_NAME_BYTES = 63;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reads the one trigger form PGCF admits on an existing table:
|
|
29
|
+
* CREATE TRIGGER name AFTER INSERT [OR UPDATE] [OR DELETE] ON schema.table FOR EACH ROW
|
|
30
|
+
* EXECUTE FUNCTION pgcf.capture('column', ...). Returns undefined for every statement that does not
|
|
31
|
+
* name pgcf, so ordinary triggers keep their existing admission. Anything else that names pgcf throws.
|
|
32
|
+
*/
|
|
33
|
+
export function readMigrationCaptureTrigger(
|
|
34
|
+
tokens: readonly MigrationSqlToken[],
|
|
35
|
+
): MigrationCaptureStatement | undefined {
|
|
36
|
+
if (!isSqlKeyword(tokens[0], "create") || !isSqlKeyword(tokens[1], "trigger")) return undefined;
|
|
37
|
+
if (!tokens.some((token) => isNamedPgcf(token, true))) return undefined;
|
|
38
|
+
return new CaptureReader(tokens).read();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class CaptureReader {
|
|
42
|
+
private position = 2;
|
|
43
|
+
|
|
44
|
+
public constructor(private readonly tokens: readonly MigrationSqlToken[]) {}
|
|
45
|
+
|
|
46
|
+
public read(): MigrationCaptureStatement {
|
|
47
|
+
const trigger = this.triggerName();
|
|
48
|
+
this.expectKeyword("after");
|
|
49
|
+
const operations = this.operations();
|
|
50
|
+
this.expectKeyword("on");
|
|
51
|
+
const schema = this.name();
|
|
52
|
+
this.expectSymbol(".");
|
|
53
|
+
const table = this.name();
|
|
54
|
+
if (!isDatabaseWatchSchema(schema)) throw unsupported();
|
|
55
|
+
for (const keyword of ["for", "each", "row", "execute", "function"]) {
|
|
56
|
+
this.expectKeyword(keyword);
|
|
57
|
+
}
|
|
58
|
+
const header = this.tokens.slice(0, this.position);
|
|
59
|
+
if (!isNamedPgcf(this.token(), false)) throw unsupported();
|
|
60
|
+
this.position += 1;
|
|
61
|
+
this.expectSymbol(".");
|
|
62
|
+
this.expectCapture();
|
|
63
|
+
this.expectSymbol("(");
|
|
64
|
+
const columns = this.columns();
|
|
65
|
+
this.expectSymbol(")");
|
|
66
|
+
if (this.position !== this.tokens.length) throw unsupported();
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
target: Object.freeze({ trigger, schema, table, operations, columns }),
|
|
69
|
+
header,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private triggerName(): string {
|
|
74
|
+
const token = this.token();
|
|
75
|
+
if (
|
|
76
|
+
(token.kind !== "word" && token.kind !== "identifier") ||
|
|
77
|
+
!isDatabaseWatchIdentifier(token.value)
|
|
78
|
+
) {
|
|
79
|
+
throw unsupported();
|
|
80
|
+
}
|
|
81
|
+
this.position += 1;
|
|
82
|
+
return token.value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Table and schema names may be quoted identifiers; the executor checks the catalog for the exact name. */
|
|
86
|
+
private name(): string {
|
|
87
|
+
const token = this.token();
|
|
88
|
+
const valid =
|
|
89
|
+
token.kind === "word"
|
|
90
|
+
? isDatabaseWatchIdentifier(token.value)
|
|
91
|
+
: token.kind === "identifier" && isQuotedName(token.value);
|
|
92
|
+
if (!valid) throw unsupported();
|
|
93
|
+
this.position += 1;
|
|
94
|
+
return token.value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private operations(): readonly DatabaseWatchOperation[] {
|
|
98
|
+
const events: DatabaseWatchOperation[] = [];
|
|
99
|
+
do {
|
|
100
|
+
const token = this.token();
|
|
101
|
+
const operation = DATABASE_WATCH_OPERATIONS.find((name) => isSqlKeyword(token, name));
|
|
102
|
+
if (operation === undefined || events.includes(operation)) throw unsupported();
|
|
103
|
+
this.position += 1;
|
|
104
|
+
events.push(operation);
|
|
105
|
+
} while (this.takeKeyword("or"));
|
|
106
|
+
return DATABASE_WATCH_OPERATIONS.filter((operation) => events.includes(operation));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private expectCapture(): void {
|
|
110
|
+
const token = this.token();
|
|
111
|
+
if ((token.kind !== "word" && token.kind !== "identifier") || token.value !== "capture") {
|
|
112
|
+
throw unsupported();
|
|
113
|
+
}
|
|
114
|
+
this.position += 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private columns(): readonly string[] {
|
|
118
|
+
const columns: string[] = [];
|
|
119
|
+
do {
|
|
120
|
+
const token = this.token();
|
|
121
|
+
if (
|
|
122
|
+
columns.length >= DATABASE_WATCH_MAX_COLUMNS ||
|
|
123
|
+
token.kind !== "string" ||
|
|
124
|
+
token.text !== `'${token.value}'` ||
|
|
125
|
+
!isDatabaseWatchIdentifier(token.value) ||
|
|
126
|
+
columns.includes(token.value)
|
|
127
|
+
) {
|
|
128
|
+
throw unsupported();
|
|
129
|
+
}
|
|
130
|
+
this.position += 1;
|
|
131
|
+
columns.push(token.value);
|
|
132
|
+
} while (this.takeSymbol(","));
|
|
133
|
+
return columns;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private token(): MigrationSqlToken {
|
|
137
|
+
const token = this.tokens[this.position];
|
|
138
|
+
if (token === undefined) throw unsupported();
|
|
139
|
+
return token;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private takeKeyword(keyword: string): boolean {
|
|
143
|
+
if (!isSqlKeyword(this.tokens[this.position], keyword)) return false;
|
|
144
|
+
this.position += 1;
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private expectKeyword(keyword: string): void {
|
|
149
|
+
if (!this.takeKeyword(keyword)) throw unsupported();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private takeSymbol(symbol: string): boolean {
|
|
153
|
+
const token = this.tokens[this.position];
|
|
154
|
+
if (token?.kind !== "symbol" || token.value !== symbol) return false;
|
|
155
|
+
this.position += 1;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private expectSymbol(symbol: string): void {
|
|
160
|
+
if (!this.takeSymbol(symbol)) throw unsupported();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The gate ignores case; the function name itself must be exactly pgcf, so "PGCF" is refused later. */
|
|
165
|
+
function isNamedPgcf(token: MigrationSqlToken, ignoreCase: boolean): boolean {
|
|
166
|
+
if (token.kind !== "word" && token.kind !== "identifier") return false;
|
|
167
|
+
return ignoreCase ? token.value.toLowerCase() === "pgcf" : token.value === "pgcf";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isQuotedName(value: string): boolean {
|
|
171
|
+
const bytes = new TextEncoder().encode(value).byteLength;
|
|
172
|
+
return bytes >= 1 && bytes <= MAX_NAME_BYTES;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function unsupported(): DatabaseMigrationAdmissionError {
|
|
176
|
+
return new DatabaseMigrationAdmissionError("unsupported");
|
|
177
|
+
}
|
|
@@ -144,6 +144,7 @@ export function rejectMigrationSqlReferences(
|
|
|
144
144
|
const token = tokens[index];
|
|
145
145
|
if (token?.kind !== "word" && token?.kind !== "identifier") continue;
|
|
146
146
|
const value = token.value.toLowerCase();
|
|
147
|
+
if (value === "pgcf") throw new DatabaseMigrationAdmissionError("unsupported");
|
|
147
148
|
if (value === "ohmyhost") {
|
|
148
149
|
if (
|
|
149
150
|
allowIdentityHelpers &&
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/** Column types a created table may declare; the closed list keeps type text out of the DDL. */
|
|
2
|
+
export const DATABASE_TABLE_COLUMN_TYPES = [
|
|
3
|
+
"text",
|
|
4
|
+
"integer",
|
|
5
|
+
"bigint",
|
|
6
|
+
"boolean",
|
|
7
|
+
"numeric",
|
|
8
|
+
"double precision",
|
|
9
|
+
"timestamptz",
|
|
10
|
+
"date",
|
|
11
|
+
"uuid",
|
|
12
|
+
"jsonb",
|
|
13
|
+
] as const;
|
|
14
|
+
export type DatabaseTableColumnType = (typeof DATABASE_TABLE_COLUMN_TYPES)[number];
|
|
15
|
+
/** `now` is DEFAULT now() on timestamptz; `uuid` is DEFAULT gen_random_uuid() on uuid. */
|
|
16
|
+
export const DATABASE_TABLE_DEFAULTS = ["now", "uuid"] as const;
|
|
17
|
+
export type DatabaseTableDefault = (typeof DATABASE_TABLE_DEFAULTS)[number];
|
|
18
|
+
export const DATABASE_TABLE_MAX_COLUMNS = 64;
|
|
19
|
+
export const DATABASE_TABLE_MAX_PRIMARY_KEY_COLUMNS = 8;
|
|
20
|
+
/** Ordinary and partitioned tables that a database may hold in its public schema. */
|
|
21
|
+
export const DATABASE_TABLE_LIMIT = 100;
|
|
22
|
+
export const DATABASE_TABLE_SCHEMA = "public";
|
|
23
|
+
|
|
24
|
+
const DATABASE_TABLE_IDENTIFIER_PATTERN = /^[a-z_][a-z0-9_]{0,62}$/u;
|
|
25
|
+
/** PostgreSQL reserved words: a table or column of such a name would need quoting everywhere it is used. */
|
|
26
|
+
const RESERVED_WORDS: ReadonlySet<string> = new Set([
|
|
27
|
+
"all",
|
|
28
|
+
"analyse",
|
|
29
|
+
"analyze",
|
|
30
|
+
"and",
|
|
31
|
+
"any",
|
|
32
|
+
"array",
|
|
33
|
+
"as",
|
|
34
|
+
"asc",
|
|
35
|
+
"asymmetric",
|
|
36
|
+
"both",
|
|
37
|
+
"case",
|
|
38
|
+
"cast",
|
|
39
|
+
"check",
|
|
40
|
+
"collate",
|
|
41
|
+
"column",
|
|
42
|
+
"constraint",
|
|
43
|
+
"create",
|
|
44
|
+
"current_catalog",
|
|
45
|
+
"current_date",
|
|
46
|
+
"current_role",
|
|
47
|
+
"current_time",
|
|
48
|
+
"current_timestamp",
|
|
49
|
+
"current_user",
|
|
50
|
+
"default",
|
|
51
|
+
"deferrable",
|
|
52
|
+
"desc",
|
|
53
|
+
"distinct",
|
|
54
|
+
"do",
|
|
55
|
+
"else",
|
|
56
|
+
"end",
|
|
57
|
+
"except",
|
|
58
|
+
"false",
|
|
59
|
+
"fetch",
|
|
60
|
+
"for",
|
|
61
|
+
"foreign",
|
|
62
|
+
"from",
|
|
63
|
+
"grant",
|
|
64
|
+
"group",
|
|
65
|
+
"having",
|
|
66
|
+
"in",
|
|
67
|
+
"initially",
|
|
68
|
+
"intersect",
|
|
69
|
+
"into",
|
|
70
|
+
"lateral",
|
|
71
|
+
"leading",
|
|
72
|
+
"limit",
|
|
73
|
+
"localtime",
|
|
74
|
+
"localtimestamp",
|
|
75
|
+
"not",
|
|
76
|
+
"null",
|
|
77
|
+
"offset",
|
|
78
|
+
"on",
|
|
79
|
+
"only",
|
|
80
|
+
"or",
|
|
81
|
+
"order",
|
|
82
|
+
"placing",
|
|
83
|
+
"primary",
|
|
84
|
+
"references",
|
|
85
|
+
"returning",
|
|
86
|
+
"select",
|
|
87
|
+
"session_user",
|
|
88
|
+
"some",
|
|
89
|
+
"symmetric",
|
|
90
|
+
"table",
|
|
91
|
+
"then",
|
|
92
|
+
"to",
|
|
93
|
+
"trailing",
|
|
94
|
+
"true",
|
|
95
|
+
"union",
|
|
96
|
+
"unique",
|
|
97
|
+
"user",
|
|
98
|
+
"using",
|
|
99
|
+
"variadic",
|
|
100
|
+
"when",
|
|
101
|
+
"where",
|
|
102
|
+
"window",
|
|
103
|
+
"with",
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
export interface DatabaseTableColumn {
|
|
107
|
+
readonly name: string;
|
|
108
|
+
readonly type: DatabaseTableColumnType;
|
|
109
|
+
readonly nullable: boolean;
|
|
110
|
+
readonly identity: boolean;
|
|
111
|
+
readonly default: DatabaseTableDefault | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A create request after parsing: nullable is concrete and primaryKey is empty when the request names none. */
|
|
115
|
+
export interface DatabaseTableRequest {
|
|
116
|
+
readonly table: string;
|
|
117
|
+
readonly columns: readonly DatabaseTableColumn[];
|
|
118
|
+
readonly primaryKey: readonly string[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface DatabaseTable {
|
|
122
|
+
readonly schema: typeof DATABASE_TABLE_SCHEMA;
|
|
123
|
+
readonly table: string;
|
|
124
|
+
readonly columns: readonly DatabaseTableColumn[];
|
|
125
|
+
readonly primaryKey: readonly string[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface DatabaseTableCreated {
|
|
129
|
+
readonly table: DatabaseTable;
|
|
130
|
+
readonly created: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface ColumnInput {
|
|
134
|
+
readonly name: string;
|
|
135
|
+
readonly type: DatabaseTableColumnType;
|
|
136
|
+
readonly nullable: boolean | undefined;
|
|
137
|
+
readonly identity: boolean;
|
|
138
|
+
readonly default: DatabaseTableDefault | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Identifiers are plain lower-case SQL names; the renderer quotes them and no other caller text reaches the DDL. */
|
|
142
|
+
export function isDatabaseTableIdentifier(value: unknown): value is string {
|
|
143
|
+
return (
|
|
144
|
+
typeof value === "string" &&
|
|
145
|
+
DATABASE_TABLE_IDENTIFIER_PATTERN.test(value) &&
|
|
146
|
+
!value.startsWith("pg_") &&
|
|
147
|
+
!RESERVED_WORDS.has(value)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function parseDatabaseTableCreateRequest(value: unknown): DatabaseTableRequest {
|
|
152
|
+
if (
|
|
153
|
+
!record(value) ||
|
|
154
|
+
!onlyKeys(value, ["table", "columns", "primaryKey"]) ||
|
|
155
|
+
!isDatabaseTableIdentifier(value["table"]) ||
|
|
156
|
+
!Array.isArray(value["columns"]) ||
|
|
157
|
+
value["columns"].length < 1 ||
|
|
158
|
+
value["columns"].length > DATABASE_TABLE_MAX_COLUMNS
|
|
159
|
+
)
|
|
160
|
+
throw new TypeError("Invalid database table request");
|
|
161
|
+
const inputs = value["columns"].map(parseColumn);
|
|
162
|
+
const names = inputs.map((column) => column.name);
|
|
163
|
+
if (new Set(names).size !== names.length || inputs.filter((column) => column.identity).length > 1)
|
|
164
|
+
throw new TypeError("Invalid database table request");
|
|
165
|
+
const primaryKey = parsePrimaryKey(value["primaryKey"], names);
|
|
166
|
+
return {
|
|
167
|
+
table: value["table"],
|
|
168
|
+
columns: inputs.map((column) => completeColumn(column, primaryKey.includes(column.name))),
|
|
169
|
+
primaryKey,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The one CREATE TABLE statement of a request, in the public schema. Identifiers are validated again here, so the
|
|
175
|
+
* renderer is safe for any caller; the statement has no trailing semicolon.
|
|
176
|
+
*/
|
|
177
|
+
export function renderCreateTableStatement(request: DatabaseTableRequest): string {
|
|
178
|
+
const definitions = request.columns.map(renderColumn);
|
|
179
|
+
if (request.primaryKey.length > 0)
|
|
180
|
+
definitions.push(`PRIMARY KEY (${request.primaryKey.map(quoted).join(",")})`);
|
|
181
|
+
return `CREATE TABLE ${databaseTableReference(request.table)} (${definitions.join(", ")})`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The quoted, schema-qualified name of a table of the public schema, as SQL statements use it. */
|
|
185
|
+
export function databaseTableReference(table: string): string {
|
|
186
|
+
return `${quoted(DATABASE_TABLE_SCHEMA)}.${quoted(table)}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function assertDatabaseTableCreated(value: unknown): asserts value is DatabaseTableCreated {
|
|
190
|
+
if (
|
|
191
|
+
!record(value) ||
|
|
192
|
+
!exact(value, ["table", "created"]) ||
|
|
193
|
+
typeof value["created"] !== "boolean" ||
|
|
194
|
+
!isDatabaseTable(value["table"])
|
|
195
|
+
)
|
|
196
|
+
throw new TypeError("Invalid database table");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseColumn(value: unknown): ColumnInput {
|
|
200
|
+
if (
|
|
201
|
+
!record(value) ||
|
|
202
|
+
!onlyKeys(value, ["name", "type", "nullable", "identity", "default"]) ||
|
|
203
|
+
!isDatabaseTableIdentifier(value["name"]) ||
|
|
204
|
+
!oneOf(DATABASE_TABLE_COLUMN_TYPES, value["type"]) ||
|
|
205
|
+
(value["nullable"] !== undefined && typeof value["nullable"] !== "boolean") ||
|
|
206
|
+
(value["identity"] !== undefined && typeof value["identity"] !== "boolean") ||
|
|
207
|
+
(value["default"] !== undefined && !oneOf(DATABASE_TABLE_DEFAULTS, value["default"]))
|
|
208
|
+
)
|
|
209
|
+
throw new TypeError("Invalid database table request");
|
|
210
|
+
const type = value["type"];
|
|
211
|
+
const identity = value["identity"] === true;
|
|
212
|
+
const defaultValue = value["default"] ?? null;
|
|
213
|
+
if (identity && type !== "integer" && type !== "bigint")
|
|
214
|
+
throw new TypeError("Invalid database table request");
|
|
215
|
+
if (identity && defaultValue !== null) throw new TypeError("Invalid database table request");
|
|
216
|
+
if (defaultValue === "now" && type !== "timestamptz")
|
|
217
|
+
throw new TypeError("Invalid database table request");
|
|
218
|
+
if (defaultValue === "uuid" && type !== "uuid")
|
|
219
|
+
throw new TypeError("Invalid database table request");
|
|
220
|
+
return {
|
|
221
|
+
name: value["name"],
|
|
222
|
+
type,
|
|
223
|
+
nullable: value["nullable"],
|
|
224
|
+
identity,
|
|
225
|
+
default: defaultValue,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function parsePrimaryKey(value: unknown, names: readonly string[]): readonly string[] {
|
|
230
|
+
if (value === undefined) return [];
|
|
231
|
+
if (
|
|
232
|
+
!Array.isArray(value) ||
|
|
233
|
+
value.length < 1 ||
|
|
234
|
+
value.length > DATABASE_TABLE_MAX_PRIMARY_KEY_COLUMNS ||
|
|
235
|
+
new Set(value).size !== value.length ||
|
|
236
|
+
!value.every((name) => typeof name === "string" && names.includes(name))
|
|
237
|
+
)
|
|
238
|
+
throw new TypeError("Invalid database table request");
|
|
239
|
+
return value as string[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Identity and primary key columns are NOT NULL; asking for a nullable one of them is a contradiction. */
|
|
243
|
+
function completeColumn(column: ColumnInput, keyed: boolean): DatabaseTableColumn {
|
|
244
|
+
const required = column.identity || keyed;
|
|
245
|
+
if (required && column.nullable === true) throw new TypeError("Invalid database table request");
|
|
246
|
+
return {
|
|
247
|
+
name: column.name,
|
|
248
|
+
type: column.type,
|
|
249
|
+
nullable: required ? false : (column.nullable ?? true),
|
|
250
|
+
identity: column.identity,
|
|
251
|
+
default: column.default,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderColumn(column: DatabaseTableColumn): string {
|
|
256
|
+
if (!oneOf(DATABASE_TABLE_COLUMN_TYPES, column.type))
|
|
257
|
+
throw new TypeError("Invalid database table column type");
|
|
258
|
+
const parts = [quoted(column.name), column.type];
|
|
259
|
+
if (!column.nullable) parts.push("NOT NULL");
|
|
260
|
+
if (column.identity) parts.push("GENERATED ALWAYS AS IDENTITY");
|
|
261
|
+
if (column.default === "now") parts.push("DEFAULT now()");
|
|
262
|
+
if (column.default === "uuid") parts.push("DEFAULT gen_random_uuid()");
|
|
263
|
+
return parts.join(" ");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function quoted(identifier: string): string {
|
|
267
|
+
if (!isDatabaseTableIdentifier(identifier))
|
|
268
|
+
throw new TypeError("Invalid database table identifier");
|
|
269
|
+
return `"${identifier}"`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function isDatabaseTable(value: unknown): boolean {
|
|
273
|
+
if (!record(value) || !exact(value, ["schema", "table", "columns", "primaryKey"])) return false;
|
|
274
|
+
const columns = value["columns"];
|
|
275
|
+
const primaryKey = value["primaryKey"];
|
|
276
|
+
if (
|
|
277
|
+
value["schema"] !== DATABASE_TABLE_SCHEMA ||
|
|
278
|
+
!isDatabaseTableIdentifier(value["table"]) ||
|
|
279
|
+
!Array.isArray(columns) ||
|
|
280
|
+
columns.length < 1 ||
|
|
281
|
+
columns.length > DATABASE_TABLE_MAX_COLUMNS ||
|
|
282
|
+
!columns.every(isDatabaseTableColumn) ||
|
|
283
|
+
!Array.isArray(primaryKey) ||
|
|
284
|
+
primaryKey.length > DATABASE_TABLE_MAX_PRIMARY_KEY_COLUMNS
|
|
285
|
+
)
|
|
286
|
+
return false;
|
|
287
|
+
const names = columns.map((column) => column.name);
|
|
288
|
+
return primaryKey.every((name) => typeof name === "string" && names.includes(name));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function isDatabaseTableColumn(value: unknown): value is DatabaseTableColumn {
|
|
292
|
+
return (
|
|
293
|
+
record(value) &&
|
|
294
|
+
exact(value, ["name", "type", "nullable", "identity", "default"]) &&
|
|
295
|
+
isDatabaseTableIdentifier(value["name"]) &&
|
|
296
|
+
oneOf(DATABASE_TABLE_COLUMN_TYPES, value["type"]) &&
|
|
297
|
+
typeof value["nullable"] === "boolean" &&
|
|
298
|
+
typeof value["identity"] === "boolean" &&
|
|
299
|
+
(value["default"] === null || oneOf(DATABASE_TABLE_DEFAULTS, value["default"]))
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function oneOf<T extends string>(allowed: readonly T[], value: unknown): value is T {
|
|
304
|
+
return typeof value === "string" && (allowed as readonly string[]).includes(value);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
308
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function onlyKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
312
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function exact(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
316
|
+
return Object.keys(value).sort().join(",") === [...keys].sort().join(",");
|
|
317
|
+
}
|
|
@@ -192,6 +192,55 @@ export const PROBLEM_POLICY = {
|
|
|
192
192
|
"The platform is still retrying this webhook delivery. Read the delivery in the delivery list; replay it only if it ends dead_lettered or the receiver needs the event again.",
|
|
193
193
|
title: "Webhook delivery still pending",
|
|
194
194
|
},
|
|
195
|
+
database_watch_limit: {
|
|
196
|
+
status: 409,
|
|
197
|
+
retryable: false,
|
|
198
|
+
action:
|
|
199
|
+
"List the project's database watches for this environment and delete one you no longer need, then create the watch again.",
|
|
200
|
+
title: "Database watch limit reached",
|
|
201
|
+
},
|
|
202
|
+
database_watch_table_unavailable: {
|
|
203
|
+
status: 409,
|
|
204
|
+
retryable: false,
|
|
205
|
+
action:
|
|
206
|
+
"The table must exist as an ordinary table owned by the application, created by a deployed migration; views, partitions, temporary tables and the schemas pgcf, ohmyhost, information_schema and pg_* cannot be watched. Fix the name or deploy the migration, then create the watch again.",
|
|
207
|
+
title: "Table cannot be watched",
|
|
208
|
+
},
|
|
209
|
+
database_watch_column_unavailable: {
|
|
210
|
+
status: 409,
|
|
211
|
+
retryable: false,
|
|
212
|
+
action:
|
|
213
|
+
"List only existing column names exactly as written, names are case-sensitive. Delete the watch before a migration drops a published column, because the write would fail.",
|
|
214
|
+
title: "Watched column does not exist",
|
|
215
|
+
},
|
|
216
|
+
database_watch_events_unavailable: {
|
|
217
|
+
status: 409,
|
|
218
|
+
retryable: false,
|
|
219
|
+
action:
|
|
220
|
+
"The platform event schema is installed when the database wakes: run any query against that environment, wait for the answer, then create the watch again. If it still fails, report the request_id through feedback.",
|
|
221
|
+
title: "Database events not installed yet",
|
|
222
|
+
},
|
|
223
|
+
database_watch_conflict: {
|
|
224
|
+
status: 409,
|
|
225
|
+
retryable: false,
|
|
226
|
+
action:
|
|
227
|
+
"A migration already attaches a capture trigger to this table: keep it and skip the watch, or remove it from the migrations, deploy and create the watch again. A table carries one capture trigger.",
|
|
228
|
+
title: "Table already has a capture trigger",
|
|
229
|
+
},
|
|
230
|
+
database_table_conflict: {
|
|
231
|
+
status: 409,
|
|
232
|
+
retryable: false,
|
|
233
|
+
action:
|
|
234
|
+
"A table with this name already exists with another definition. Choose another table name, or create the table again with the same columns, types, nullability, identity, defaults and primary key it has.",
|
|
235
|
+
title: "Table already exists with another definition",
|
|
236
|
+
},
|
|
237
|
+
database_table_limit: {
|
|
238
|
+
status: 409,
|
|
239
|
+
retryable: false,
|
|
240
|
+
action:
|
|
241
|
+
"This database already holds the maximum number of tables in its public schema, so no table can be added. Use an existing table, or remove a table you no longer need through a deployed migration, then create the table again.",
|
|
242
|
+
title: "Database table limit reached",
|
|
243
|
+
},
|
|
195
244
|
project_notes_conflict: {
|
|
196
245
|
status: 409,
|
|
197
246
|
retryable: false,
|
|
@@ -502,7 +551,7 @@ export const PROBLEM_POLICY = {
|
|
|
502
551
|
status: 503,
|
|
503
552
|
retryable: false,
|
|
504
553
|
action:
|
|
505
|
-
"Report the platform user-key permission configuration through feedback. Repeated login
|
|
554
|
+
"Report the platform user-key permission configuration through feedback with this request_id; it affects every token the same way, whatever the integration. Repeated login does not fix it. After the platform confirms the fix, repeat the same create with the same name and Idempotency-Key.",
|
|
506
555
|
title: "Token permissions unavailable",
|
|
507
556
|
},
|
|
508
557
|
reconciliation_exhausted: {
|
|
@@ -23,6 +23,8 @@ export {
|
|
|
23
23
|
createOrganization,
|
|
24
24
|
createProject,
|
|
25
25
|
createProjectDatabaseAccess,
|
|
26
|
+
createProjectDatabaseTable,
|
|
27
|
+
createProjectDatabaseWatch,
|
|
26
28
|
createProjectDatabaseWebhook,
|
|
27
29
|
createProjectDataChange,
|
|
28
30
|
createProjectDevAccessTicket,
|
|
@@ -35,6 +37,7 @@ export {
|
|
|
35
37
|
deletePaidProjectDomain,
|
|
36
38
|
deleteProject,
|
|
37
39
|
deleteProjectDatabaseSchedule,
|
|
40
|
+
deleteProjectDatabaseWatch,
|
|
38
41
|
deleteProjectDatabaseWebhook,
|
|
39
42
|
detachProjectMailDomainBinding,
|
|
40
43
|
diffManagedSource,
|
|
@@ -91,6 +94,7 @@ export {
|
|
|
91
94
|
listProjectDatabaseAccess,
|
|
92
95
|
listProjectDatabaseScheduleRuns,
|
|
93
96
|
listProjectDatabaseSchedules,
|
|
97
|
+
listProjectDatabaseWatches,
|
|
94
98
|
listProjectDatabaseWebhookDeliveries,
|
|
95
99
|
listProjectDatabaseWebhooks,
|
|
96
100
|
listProjects,
|
|
@@ -271,6 +275,16 @@ export type {
|
|
|
271
275
|
CreateProjectDatabaseAccessErrors,
|
|
272
276
|
CreateProjectDatabaseAccessResponse,
|
|
273
277
|
CreateProjectDatabaseAccessResponses,
|
|
278
|
+
CreateProjectDatabaseTableData,
|
|
279
|
+
CreateProjectDatabaseTableError,
|
|
280
|
+
CreateProjectDatabaseTableErrors,
|
|
281
|
+
CreateProjectDatabaseTableResponse,
|
|
282
|
+
CreateProjectDatabaseTableResponses,
|
|
283
|
+
CreateProjectDatabaseWatchData,
|
|
284
|
+
CreateProjectDatabaseWatchError,
|
|
285
|
+
CreateProjectDatabaseWatchErrors,
|
|
286
|
+
CreateProjectDatabaseWatchResponse,
|
|
287
|
+
CreateProjectDatabaseWatchResponses,
|
|
274
288
|
CreateProjectDatabaseWebhookData,
|
|
275
289
|
CreateProjectDatabaseWebhookError,
|
|
276
290
|
CreateProjectDatabaseWebhookErrors,
|
|
@@ -325,6 +339,17 @@ export type {
|
|
|
325
339
|
DatabaseScheduleRunAccepted,
|
|
326
340
|
DatabaseScheduleRunPage,
|
|
327
341
|
DatabaseScheduleSetRequest,
|
|
342
|
+
DatabaseTable,
|
|
343
|
+
DatabaseTableColumn,
|
|
344
|
+
DatabaseTableColumnRequest,
|
|
345
|
+
DatabaseTableColumnType,
|
|
346
|
+
DatabaseTableCreated,
|
|
347
|
+
DatabaseTableCreateRequest,
|
|
348
|
+
DatabaseWatch,
|
|
349
|
+
DatabaseWatchCreateRequest,
|
|
350
|
+
DatabaseWatchDeleted,
|
|
351
|
+
DatabaseWatchId,
|
|
352
|
+
DatabaseWatchPage,
|
|
328
353
|
DatabaseWebhook,
|
|
329
354
|
DatabaseWebhookCreated,
|
|
330
355
|
DatabaseWebhookCreateRequest,
|
|
@@ -332,6 +357,8 @@ export type {
|
|
|
332
357
|
DatabaseWebhookDelivery,
|
|
333
358
|
DatabaseWebhookDeliveryId,
|
|
334
359
|
DatabaseWebhookDeliveryPage,
|
|
360
|
+
DatabaseWebhookEventPattern,
|
|
361
|
+
DatabaseWebhookEventPatternWritable,
|
|
335
362
|
DatabaseWebhookEventType,
|
|
336
363
|
DatabaseWebhookId,
|
|
337
364
|
DatabaseWebhookPage,
|
|
@@ -364,6 +391,11 @@ export type {
|
|
|
364
391
|
DeleteProjectDatabaseScheduleErrors,
|
|
365
392
|
DeleteProjectDatabaseScheduleResponse,
|
|
366
393
|
DeleteProjectDatabaseScheduleResponses,
|
|
394
|
+
DeleteProjectDatabaseWatchData,
|
|
395
|
+
DeleteProjectDatabaseWatchError,
|
|
396
|
+
DeleteProjectDatabaseWatchErrors,
|
|
397
|
+
DeleteProjectDatabaseWatchResponse,
|
|
398
|
+
DeleteProjectDatabaseWatchResponses,
|
|
367
399
|
DeleteProjectDatabaseWebhookData,
|
|
368
400
|
DeleteProjectDatabaseWebhookError,
|
|
369
401
|
DeleteProjectDatabaseWebhookErrors,
|
|
@@ -685,6 +717,11 @@ export type {
|
|
|
685
717
|
ListProjectDatabaseSchedulesErrors,
|
|
686
718
|
ListProjectDatabaseSchedulesResponse,
|
|
687
719
|
ListProjectDatabaseSchedulesResponses,
|
|
720
|
+
ListProjectDatabaseWatchesData,
|
|
721
|
+
ListProjectDatabaseWatchesError,
|
|
722
|
+
ListProjectDatabaseWatchesErrors,
|
|
723
|
+
ListProjectDatabaseWatchesResponse,
|
|
724
|
+
ListProjectDatabaseWatchesResponses,
|
|
688
725
|
ListProjectDatabaseWebhookDeliveriesData,
|
|
689
726
|
ListProjectDatabaseWebhookDeliveriesError,
|
|
690
727
|
ListProjectDatabaseWebhookDeliveriesErrors,
|