@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
|
@@ -20,6 +20,13 @@ import {
|
|
|
20
20
|
type ProjectDatabaseWriteReceipt,
|
|
21
21
|
} from "@ohmyhost/contracts/database-access";
|
|
22
22
|
import { assertDatabaseCompute, type DatabaseCompute } from "@ohmyhost/contracts/database-compute";
|
|
23
|
+
import {
|
|
24
|
+
DATABASE_TABLE_COLUMN_TYPES,
|
|
25
|
+
DATABASE_TABLE_DEFAULTS,
|
|
26
|
+
isDatabaseTableIdentifier,
|
|
27
|
+
type DatabaseTableColumnType,
|
|
28
|
+
type DatabaseTableDefault,
|
|
29
|
+
} from "@ohmyhost/contracts/database-tables";
|
|
23
30
|
import {
|
|
24
31
|
assertDatabaseExecutionUsagePage,
|
|
25
32
|
assertDatabaseSchedule,
|
|
@@ -33,6 +40,9 @@ import {
|
|
|
33
40
|
assertDatabaseWebhookDeliveryPage,
|
|
34
41
|
assertDatabaseWebhookPage,
|
|
35
42
|
assertDatabaseWebhookReplayAccepted,
|
|
43
|
+
assertDatabaseWatch,
|
|
44
|
+
assertDatabaseWatchDeleted,
|
|
45
|
+
assertDatabaseWatchPage,
|
|
36
46
|
assertDatabaseWebhookTest,
|
|
37
47
|
type DatabaseExecutionUsagePage,
|
|
38
48
|
type DatabaseSchedule,
|
|
@@ -45,7 +55,11 @@ import {
|
|
|
45
55
|
type DatabaseWebhookCreated,
|
|
46
56
|
type DatabaseWebhookDeleted,
|
|
47
57
|
type DatabaseWebhookDeliveryPage,
|
|
48
|
-
type
|
|
58
|
+
type DatabaseWatch,
|
|
59
|
+
type DatabaseWatchDeleted,
|
|
60
|
+
type DatabaseWatchPage,
|
|
61
|
+
type DatabaseWatchRequest,
|
|
62
|
+
type DatabaseWebhookEventPattern,
|
|
49
63
|
type DatabaseWebhookPage,
|
|
50
64
|
type DatabaseWebhookReplayAccepted,
|
|
51
65
|
type DatabaseWebhookTestResult,
|
|
@@ -152,6 +166,10 @@ import {
|
|
|
152
166
|
getProjectDatabaseWebhook,
|
|
153
167
|
deleteProjectDatabaseWebhook,
|
|
154
168
|
testProjectDatabaseWebhook,
|
|
169
|
+
createProjectDatabaseWatch,
|
|
170
|
+
listProjectDatabaseWatches,
|
|
171
|
+
deleteProjectDatabaseWatch,
|
|
172
|
+
createProjectDatabaseTable,
|
|
155
173
|
listProjectDatabaseWebhookDeliveries,
|
|
156
174
|
replayProjectDatabaseWebhookDelivery,
|
|
157
175
|
setProjectDatabaseSchedule,
|
|
@@ -205,6 +223,39 @@ export interface ProjectCreateInput {
|
|
|
205
223
|
readonly idempotencyKey: string;
|
|
206
224
|
}
|
|
207
225
|
|
|
226
|
+
/** One column of a table to create; nullable, identity and default are optional as the REST body allows. */
|
|
227
|
+
export interface DatabaseTableColumnInput {
|
|
228
|
+
readonly name: string;
|
|
229
|
+
readonly type: DatabaseTableColumnType;
|
|
230
|
+
readonly nullable?: boolean;
|
|
231
|
+
readonly identity?: boolean;
|
|
232
|
+
readonly default?: DatabaseTableDefault;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface DatabaseTableCreateInput {
|
|
236
|
+
readonly table: string;
|
|
237
|
+
readonly columns: readonly DatabaseTableColumnInput[];
|
|
238
|
+
readonly primaryKey: readonly string[];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The created or identical existing table, every column with its concrete nullability and default. */
|
|
242
|
+
export interface DatabaseTableAnswer {
|
|
243
|
+
readonly table: {
|
|
244
|
+
readonly environment: "dev" | "prod";
|
|
245
|
+
readonly schema: "public";
|
|
246
|
+
readonly table: string;
|
|
247
|
+
readonly columns: readonly {
|
|
248
|
+
readonly name: string;
|
|
249
|
+
readonly type: DatabaseTableColumnType;
|
|
250
|
+
readonly nullable: boolean;
|
|
251
|
+
readonly identity: boolean;
|
|
252
|
+
readonly default: DatabaseTableDefault | null;
|
|
253
|
+
}[];
|
|
254
|
+
readonly primary_key: readonly string[];
|
|
255
|
+
readonly created: boolean;
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
208
259
|
export interface ProductApi {
|
|
209
260
|
readonly managedSource?: ManagedSourceApi;
|
|
210
261
|
planProjectDataChange(input: {
|
|
@@ -328,7 +379,7 @@ export interface ProductApi {
|
|
|
328
379
|
readonly environment: "dev" | "prod";
|
|
329
380
|
readonly idempotencyKey: string;
|
|
330
381
|
readonly url: string;
|
|
331
|
-
readonly eventTypes: readonly
|
|
382
|
+
readonly eventTypes: readonly DatabaseWebhookEventPattern[];
|
|
332
383
|
readonly headers: Readonly<Record<string, string>>;
|
|
333
384
|
}): Promise<DatabaseWebhookCreated>;
|
|
334
385
|
listProjectDatabaseWebhooks(input: {
|
|
@@ -350,6 +401,28 @@ export interface ProductApi {
|
|
|
350
401
|
readonly environment: "dev" | "prod";
|
|
351
402
|
readonly webhookId: string;
|
|
352
403
|
}): Promise<DatabaseWebhookTestResult>;
|
|
404
|
+
createProjectDatabaseWatch(input: {
|
|
405
|
+
readonly projectId: string;
|
|
406
|
+
readonly environment: "dev" | "prod";
|
|
407
|
+
readonly idempotencyKey: string;
|
|
408
|
+
readonly request: DatabaseWatchRequest;
|
|
409
|
+
}): Promise<DatabaseWatch>;
|
|
410
|
+
listProjectDatabaseWatches(input: {
|
|
411
|
+
readonly projectId: string;
|
|
412
|
+
readonly environment: "dev" | "prod";
|
|
413
|
+
}): Promise<DatabaseWatchPage>;
|
|
414
|
+
deleteProjectDatabaseWatch(input: {
|
|
415
|
+
readonly projectId: string;
|
|
416
|
+
readonly environment: "dev" | "prod";
|
|
417
|
+
readonly idempotencyKey: string;
|
|
418
|
+
readonly watchId: string;
|
|
419
|
+
}): Promise<DatabaseWatchDeleted>;
|
|
420
|
+
createProjectDatabaseTable(input: {
|
|
421
|
+
readonly projectId: string;
|
|
422
|
+
readonly environment: "dev" | "prod";
|
|
423
|
+
readonly idempotencyKey: string;
|
|
424
|
+
readonly request: DatabaseTableCreateInput;
|
|
425
|
+
}): Promise<DatabaseTableAnswer>;
|
|
353
426
|
listProjectDatabaseWebhookDeliveries(input: {
|
|
354
427
|
readonly projectId: string;
|
|
355
428
|
readonly environment: "dev" | "prod";
|
|
@@ -566,9 +639,55 @@ export interface ProductApi {
|
|
|
566
639
|
|
|
567
640
|
/** A webhook answer that names another environment, webhook or delivery than the request did. */
|
|
568
641
|
const WEBHOOK_SCOPE_MISMATCH = "Webhook response scope mismatch";
|
|
642
|
+
const WATCH_SCOPE_MISMATCH = "Watch response scope mismatch";
|
|
643
|
+
const TABLE_SCOPE_MISMATCH = "Table response scope mismatch";
|
|
569
644
|
/** A schedule answer that names another environment or schedule than the request did. */
|
|
570
645
|
const SCHEDULE_SCOPE_MISMATCH = "Schedule response scope mismatch";
|
|
571
646
|
|
|
647
|
+
/** The REST answer read exactly: no extra keys, closed column types and defaults, keys among the columns. */
|
|
648
|
+
export function assertDatabaseTableAnswer(value: unknown): asserts value is DatabaseTableAnswer {
|
|
649
|
+
const table = isRecordValue(value) ? value["table"] : undefined;
|
|
650
|
+
const columns = isRecordValue(table) ? table["columns"] : undefined;
|
|
651
|
+
const primaryKey = isRecordValue(table) ? table["primary_key"] : undefined;
|
|
652
|
+
if (
|
|
653
|
+
!isRecordValue(table) ||
|
|
654
|
+
!hasExactKeys(table, ["environment", "schema", "table", "columns", "primary_key", "created"]) ||
|
|
655
|
+
(table["environment"] !== "dev" && table["environment"] !== "prod") ||
|
|
656
|
+
table["schema"] !== "public" ||
|
|
657
|
+
!isDatabaseTableIdentifier(table["table"]) ||
|
|
658
|
+
typeof table["created"] !== "boolean" ||
|
|
659
|
+
!Array.isArray(columns) ||
|
|
660
|
+
columns.length < 1 ||
|
|
661
|
+
!columns.every(isDatabaseTableAnswerColumn) ||
|
|
662
|
+
!Array.isArray(primaryKey) ||
|
|
663
|
+
!primaryKey.every((name: unknown) =>
|
|
664
|
+
columns.some((column: Record<string, unknown>) => column["name"] === name),
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
throw new TypeError("Invalid database table answer");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function isDatabaseTableAnswerColumn(value: unknown): boolean {
|
|
671
|
+
return (
|
|
672
|
+
isRecordValue(value) &&
|
|
673
|
+
hasExactKeys(value, ["name", "type", "nullable", "identity", "default"]) &&
|
|
674
|
+
isDatabaseTableIdentifier(value["name"]) &&
|
|
675
|
+
(DATABASE_TABLE_COLUMN_TYPES as readonly unknown[]).includes(value["type"]) &&
|
|
676
|
+
typeof value["nullable"] === "boolean" &&
|
|
677
|
+
typeof value["identity"] === "boolean" &&
|
|
678
|
+
(value["default"] === null ||
|
|
679
|
+
(DATABASE_TABLE_DEFAULTS as readonly unknown[]).includes(value["default"]))
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function isRecordValue(value: unknown): value is Record<string, unknown> {
|
|
684
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
688
|
+
return Object.keys(value).sort().join(",") === [...keys].sort().join(",");
|
|
689
|
+
}
|
|
690
|
+
|
|
572
691
|
export class GeneratedSdkProductApi implements ProductApi {
|
|
573
692
|
readonly #client: OhMyHostClient;
|
|
574
693
|
public readonly managedSource: ManagedSourceApi;
|
|
@@ -889,6 +1008,87 @@ export class GeneratedSdkProductApi implements ProductApi {
|
|
|
889
1008
|
return value;
|
|
890
1009
|
}
|
|
891
1010
|
|
|
1011
|
+
public async createProjectDatabaseWatch(
|
|
1012
|
+
input: Parameters<ProductApi["createProjectDatabaseWatch"]>[0],
|
|
1013
|
+
): Promise<DatabaseWatch> {
|
|
1014
|
+
const value = await createProjectDatabaseWatch(
|
|
1015
|
+
{
|
|
1016
|
+
project_id: input.projectId,
|
|
1017
|
+
"Idempotency-Key": input.idempotencyKey,
|
|
1018
|
+
databaseWatchCreateRequest: {
|
|
1019
|
+
environment: input.environment,
|
|
1020
|
+
schema: input.request.schema,
|
|
1021
|
+
table: input.request.table,
|
|
1022
|
+
columns: [...input.request.columns],
|
|
1023
|
+
operations: [...input.request.operations],
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
{ client: this.#client },
|
|
1027
|
+
);
|
|
1028
|
+
assertDatabaseWatch(value);
|
|
1029
|
+
if (
|
|
1030
|
+
value.environment !== input.environment ||
|
|
1031
|
+
value.schema !== input.request.schema ||
|
|
1032
|
+
value.table !== input.request.table
|
|
1033
|
+
)
|
|
1034
|
+
throw new TypeError(WATCH_SCOPE_MISMATCH);
|
|
1035
|
+
return value;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
public async listProjectDatabaseWatches(
|
|
1039
|
+
input: Parameters<ProductApi["listProjectDatabaseWatches"]>[0],
|
|
1040
|
+
): Promise<DatabaseWatchPage> {
|
|
1041
|
+
const value = await listProjectDatabaseWatches(
|
|
1042
|
+
{ project_id: input.projectId, environment: input.environment },
|
|
1043
|
+
{ client: this.#client },
|
|
1044
|
+
);
|
|
1045
|
+
assertDatabaseWatchPage(value);
|
|
1046
|
+
if (value.items.some((item) => item.environment !== input.environment))
|
|
1047
|
+
throw new TypeError(WATCH_SCOPE_MISMATCH);
|
|
1048
|
+
return value;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
public async deleteProjectDatabaseWatch(
|
|
1052
|
+
input: Parameters<ProductApi["deleteProjectDatabaseWatch"]>[0],
|
|
1053
|
+
): Promise<DatabaseWatchDeleted> {
|
|
1054
|
+
const value = await deleteProjectDatabaseWatch(
|
|
1055
|
+
{
|
|
1056
|
+
project_id: input.projectId,
|
|
1057
|
+
environment: input.environment,
|
|
1058
|
+
"Idempotency-Key": input.idempotencyKey,
|
|
1059
|
+
watch_id: input.watchId,
|
|
1060
|
+
},
|
|
1061
|
+
{ client: this.#client },
|
|
1062
|
+
);
|
|
1063
|
+
assertDatabaseWatchDeleted(value);
|
|
1064
|
+
if (value.id !== input.watchId) throw new TypeError(WATCH_SCOPE_MISMATCH);
|
|
1065
|
+
return value;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
public async createProjectDatabaseTable(
|
|
1069
|
+
input: Parameters<ProductApi["createProjectDatabaseTable"]>[0],
|
|
1070
|
+
): Promise<DatabaseTableAnswer> {
|
|
1071
|
+
const value = await createProjectDatabaseTable(
|
|
1072
|
+
{
|
|
1073
|
+
project_id: input.projectId,
|
|
1074
|
+
"Idempotency-Key": input.idempotencyKey,
|
|
1075
|
+
databaseTableCreateRequest: {
|
|
1076
|
+
environment: input.environment,
|
|
1077
|
+
table: input.request.table,
|
|
1078
|
+
columns: [...input.request.columns],
|
|
1079
|
+
...(input.request.primaryKey.length === 0
|
|
1080
|
+
? {}
|
|
1081
|
+
: { primary_key: [...input.request.primaryKey] }),
|
|
1082
|
+
},
|
|
1083
|
+
},
|
|
1084
|
+
{ client: this.#client },
|
|
1085
|
+
);
|
|
1086
|
+
assertDatabaseTableAnswer(value);
|
|
1087
|
+
if (value.table.environment !== input.environment || value.table.table !== input.request.table)
|
|
1088
|
+
throw new TypeError(TABLE_SCOPE_MISMATCH);
|
|
1089
|
+
return value;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
892
1092
|
/** The signing secret exists only in this answer; the CLI prints it once and stores nothing. */
|
|
893
1093
|
public async createProjectDatabaseWebhook(
|
|
894
1094
|
input: Parameters<ProductApi["createProjectDatabaseWebhook"]>[0],
|
|
@@ -132,6 +132,11 @@
|
|
|
132
132
|
"types": "./dist/database-events.d.ts",
|
|
133
133
|
"import": "./dist/database-events.js"
|
|
134
134
|
},
|
|
135
|
+
"./database-tables": {
|
|
136
|
+
"source": "./src/database-tables.ts",
|
|
137
|
+
"types": "./dist/database-tables.d.ts",
|
|
138
|
+
"import": "./dist/database-tables.js"
|
|
139
|
+
},
|
|
135
140
|
"./functions": {
|
|
136
141
|
"source": "./src/functions.ts",
|
|
137
142
|
"types": "./dist/functions.d.ts",
|
|
@@ -11,6 +11,13 @@ export const DATABASE_WEBHOOK_EVENT_TYPES = [
|
|
|
11
11
|
"database.deleted",
|
|
12
12
|
] as const;
|
|
13
13
|
export type DatabaseWebhookEventType = (typeof DATABASE_WEBHOOK_EVENT_TYPES)[number];
|
|
14
|
+
/** Row-change events of a watched table; the topic is derived from the table name by dataEventTopic. */
|
|
15
|
+
export type DatabaseDataEventType = `data.${string}`;
|
|
16
|
+
export type DatabaseWebhookEventPattern = DatabaseWebhookEventType | DatabaseDataEventType;
|
|
17
|
+
export const DATABASE_WATCH_OPERATIONS = ["insert", "update", "delete"] as const;
|
|
18
|
+
export type DatabaseWatchOperation = (typeof DATABASE_WATCH_OPERATIONS)[number];
|
|
19
|
+
export const DATABASE_WATCH_MAX_COLUMNS = 32;
|
|
20
|
+
export const DATABASE_WATCH_DEFAULT_SCHEMA = "public";
|
|
14
21
|
/** Per environment; the platform-wide provider cap is shared by every customer. */
|
|
15
22
|
export const DATABASE_WEBHOOK_LIMIT = 5;
|
|
16
23
|
export const DATABASE_WEBHOOK_MAX_EVENT_TYPES = 16;
|
|
@@ -66,9 +73,41 @@ export type DatabaseWebhookDeliveryState = "pending" | "delivered" | "dead_lette
|
|
|
66
73
|
export type DatabaseWebhookDeliveryError = "timeout" | "network" | "http" | "expired" | "permanent";
|
|
67
74
|
export type DatabaseWebhookTestError = "timeout" | "network" | "http";
|
|
68
75
|
|
|
76
|
+
export interface DatabaseWatchRequest {
|
|
77
|
+
readonly schema: string;
|
|
78
|
+
readonly table: string;
|
|
79
|
+
readonly columns: readonly string[];
|
|
80
|
+
readonly operations: readonly DatabaseWatchOperation[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DatabaseWatchTarget {
|
|
84
|
+
readonly schema: string;
|
|
85
|
+
readonly table: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DatabaseWatch {
|
|
89
|
+
readonly id: string;
|
|
90
|
+
readonly environment: DatabaseEnvironment;
|
|
91
|
+
readonly schema: string;
|
|
92
|
+
readonly table: string;
|
|
93
|
+
readonly columns: readonly string[];
|
|
94
|
+
readonly operations: readonly DatabaseWatchOperation[];
|
|
95
|
+
readonly topic: string;
|
|
96
|
+
readonly event_type: DatabaseDataEventType;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface DatabaseWatchPage {
|
|
100
|
+
readonly items: readonly DatabaseWatch[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DatabaseWatchDeleted {
|
|
104
|
+
readonly id: string;
|
|
105
|
+
readonly deleted: true;
|
|
106
|
+
}
|
|
107
|
+
|
|
69
108
|
export interface DatabaseWebhookCreateRequest {
|
|
70
109
|
readonly url: string;
|
|
71
|
-
readonly event_types: readonly
|
|
110
|
+
readonly event_types: readonly DatabaseWebhookEventPattern[];
|
|
72
111
|
readonly headers: Readonly<Record<string, string>>;
|
|
73
112
|
}
|
|
74
113
|
|
|
@@ -90,7 +129,7 @@ export interface DatabaseWebhook {
|
|
|
90
129
|
readonly id: string;
|
|
91
130
|
readonly environment: DatabaseEnvironment;
|
|
92
131
|
readonly url: string;
|
|
93
|
-
readonly event_types: readonly
|
|
132
|
+
readonly event_types: readonly DatabaseWebhookEventPattern[];
|
|
94
133
|
readonly header_names: readonly string[];
|
|
95
134
|
readonly created_at: string;
|
|
96
135
|
}
|
|
@@ -112,7 +151,7 @@ export interface DatabaseWebhookDeleted {
|
|
|
112
151
|
export interface DatabaseWebhookDelivery {
|
|
113
152
|
readonly id: string;
|
|
114
153
|
readonly event_id: string;
|
|
115
|
-
readonly event_type: DatabaseWebhookEventType;
|
|
154
|
+
readonly event_type: DatabaseWebhookEventType | DatabaseDataEventType;
|
|
116
155
|
readonly webhook_id: string;
|
|
117
156
|
readonly state: DatabaseWebhookDeliveryState;
|
|
118
157
|
readonly attempts: number;
|
|
@@ -197,7 +236,23 @@ export interface DatabaseExecutionUsagePage {
|
|
|
197
236
|
const WEBHOOK_KEYS = ["id", "environment", "url", "event_types", "header_names", "created_at"];
|
|
198
237
|
const WEBHOOK_ID_PATTERN = /^whk_[a-z0-9]{20}$/u;
|
|
199
238
|
const DELIVERY_ID_PATTERN = /^dlv_[0-9a-f]{20}$/u;
|
|
200
|
-
const EVENT_ID_PATTERN =
|
|
239
|
+
const EVENT_ID_PATTERN =
|
|
240
|
+
/^(?:evt_[0-9a-f]{20}|[a-z][a-z0-9]{19}:[1-9][0-9]{0,15}:[1-9][0-9]{0,15})$/u;
|
|
241
|
+
const DATA_SUBSCRIPTION_PATTERN = /^data\.(?:\*|[a-z][a-z0-9_]{0,47})$/u;
|
|
242
|
+
const DATA_EVENT_TYPE_PATTERN = /^data\.[a-z][a-z0-9_]{0,47}$/u;
|
|
243
|
+
const DATABASE_WATCH_ID_PATTERN = /^pgcf_watch_[0-9a-f]{8}$/u;
|
|
244
|
+
const DATABASE_WATCH_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,62}$/u;
|
|
245
|
+
const DATABASE_WATCH_FORBIDDEN_SCHEMAS = ["information_schema", "pgcf", "ohmyhost"];
|
|
246
|
+
const DATABASE_WATCH_KEYS = [
|
|
247
|
+
"id",
|
|
248
|
+
"environment",
|
|
249
|
+
"schema",
|
|
250
|
+
"table",
|
|
251
|
+
"columns",
|
|
252
|
+
"operations",
|
|
253
|
+
"topic",
|
|
254
|
+
"event_type",
|
|
255
|
+
];
|
|
201
256
|
const SCHEDULE_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,62}$/u;
|
|
202
257
|
const CURSOR_PATTERN = /^[A-Za-z0-9._|:=+/-]{1,200}$/u;
|
|
203
258
|
const HEADER_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9-]{0,63}$/u;
|
|
@@ -225,11 +280,79 @@ export function parseDatabaseWebhookId(value: unknown): string {
|
|
|
225
280
|
return value;
|
|
226
281
|
}
|
|
227
282
|
|
|
283
|
+
export function parseDatabaseWatchId(value: unknown): string {
|
|
284
|
+
if (!isDatabaseWatchTriggerName(value)) throw new TypeError("Invalid database watch id");
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
|
|
228
288
|
export function parseDatabaseDeliveryId(value: unknown): string {
|
|
229
289
|
if (!matches(value, DELIVERY_ID_PATTERN)) throw new TypeError("Invalid database delivery id");
|
|
230
290
|
return value;
|
|
231
291
|
}
|
|
232
292
|
|
|
293
|
+
/** The trigger names of the watch service; migrations may not create them, so the watch count stays exact. */
|
|
294
|
+
export function isDatabaseWatchTriggerName(value: unknown): value is string {
|
|
295
|
+
return matches(value, DATABASE_WATCH_ID_PATTERN);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Identifiers are plain SQL names: no quotes, dots or spaces; the caller quotes them. */
|
|
299
|
+
export function isDatabaseWatchIdentifier(value: unknown): value is string {
|
|
300
|
+
return matches(value, DATABASE_WATCH_IDENTIFIER_PATTERN);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Reserved platform schemas stay out of reach; the check ignores case like PostgreSQL does. */
|
|
304
|
+
export function isDatabaseWatchSchema(value: unknown): value is string {
|
|
305
|
+
if (!isDatabaseWatchIdentifier(value)) return false;
|
|
306
|
+
const lower = value.toLowerCase();
|
|
307
|
+
return !lower.startsWith("pg_") && !DATABASE_WATCH_FORBIDDEN_SCHEMAS.includes(lower);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The topic of a watched table, the same rule pgcf.capture applies in SQL: lower-case, every
|
|
312
|
+
* character outside [a-z0-9_] becomes '_', 't_' before a name that does not start with a letter,
|
|
313
|
+
* at most 48 characters. Lower-casing is per character, like PostgreSQL's simple lower() of the
|
|
314
|
+
* builtin C.UTF-8 locale that PGCF's initdb uses (SELECT lower(U&'\0130'), lower(U&'\212A') answers 'i' and 'k').
|
|
315
|
+
*/
|
|
316
|
+
export function dataEventTopic(tableName: string): string {
|
|
317
|
+
const mapped = Array.from(tableName, (character) => {
|
|
318
|
+
const lower = Array.from(character.toLowerCase())[0] ?? "_";
|
|
319
|
+
return /^[a-z0-9_]$/u.test(lower) ? lower : "_";
|
|
320
|
+
}).join("");
|
|
321
|
+
const topic = /^[a-z]/u.test(mapped) ? mapped : `t_${mapped}`;
|
|
322
|
+
return topic.slice(0, 48);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function parseDatabaseWatchCreateRequest(value: unknown): DatabaseWatchRequest {
|
|
326
|
+
if (
|
|
327
|
+
!record(value) ||
|
|
328
|
+
!onlyKeys(value, ["schema", "table", "columns", "operations"]) ||
|
|
329
|
+
!isDatabaseWatchIdentifier(value["table"]) ||
|
|
330
|
+
!Array.isArray(value["columns"]) ||
|
|
331
|
+
value["columns"].length < 1 ||
|
|
332
|
+
value["columns"].length > DATABASE_WATCH_MAX_COLUMNS ||
|
|
333
|
+
!value["columns"].every(isDatabaseWatchIdentifier) ||
|
|
334
|
+
new Set(value["columns"]).size !== value["columns"].length
|
|
335
|
+
)
|
|
336
|
+
throw new TypeError("Invalid database watch request");
|
|
337
|
+
return {
|
|
338
|
+
...parseDatabaseWatchTarget({ schema: value["schema"], table: value["table"] }),
|
|
339
|
+
columns: value["columns"] as string[],
|
|
340
|
+
operations: parseWatchOperations(value["operations"]),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function parseDatabaseWatchTarget(value: unknown): DatabaseWatchTarget {
|
|
345
|
+
if (
|
|
346
|
+
!record(value) ||
|
|
347
|
+
!onlyKeys(value, ["schema", "table"]) ||
|
|
348
|
+
!isDatabaseWatchIdentifier(value["table"])
|
|
349
|
+
)
|
|
350
|
+
throw new TypeError("Invalid database watch target");
|
|
351
|
+
const schema = value["schema"] === undefined ? DATABASE_WATCH_DEFAULT_SCHEMA : value["schema"];
|
|
352
|
+
if (!isDatabaseWatchSchema(schema)) throw new TypeError("Invalid database watch target");
|
|
353
|
+
return { schema, table: value["table"] };
|
|
354
|
+
}
|
|
355
|
+
|
|
233
356
|
export function parseDatabaseScheduleName(value: unknown): string {
|
|
234
357
|
if (!matches(value, SCHEDULE_NAME_PATTERN)) throw new TypeError("Invalid database schedule name");
|
|
235
358
|
return value;
|
|
@@ -307,6 +430,41 @@ export function assertDatabaseWebhook(value: unknown): asserts value is Database
|
|
|
307
430
|
assertWebhookFields(value);
|
|
308
431
|
}
|
|
309
432
|
|
|
433
|
+
export function assertDatabaseWatch(value: unknown): asserts value is DatabaseWatch {
|
|
434
|
+
if (
|
|
435
|
+
!record(value) ||
|
|
436
|
+
!exact(value, DATABASE_WATCH_KEYS) ||
|
|
437
|
+
!matches(value["id"], DATABASE_WATCH_ID_PATTERN) ||
|
|
438
|
+
!oneOf(["dev", "prod"] as const, value["environment"]) ||
|
|
439
|
+
!isDatabaseWatchSchema(value["schema"]) ||
|
|
440
|
+
!isDatabaseWatchIdentifier(value["table"]) ||
|
|
441
|
+
!Array.isArray(value["columns"]) ||
|
|
442
|
+
value["columns"].length < 1 ||
|
|
443
|
+
value["columns"].length > DATABASE_WATCH_MAX_COLUMNS ||
|
|
444
|
+
!value["columns"].every(isDatabaseWatchIdentifier) ||
|
|
445
|
+
!isCanonicalOperations(value["operations"]) ||
|
|
446
|
+
value["topic"] !== dataEventTopic(value["table"]) ||
|
|
447
|
+
value["event_type"] !== `data.${value["topic"]}`
|
|
448
|
+
)
|
|
449
|
+
throw new TypeError("Invalid database watch");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function assertDatabaseWatchPage(value: unknown): asserts value is DatabaseWatchPage {
|
|
453
|
+
if (!record(value) || !exact(value, ["items"]) || !pageItems(value["items"]))
|
|
454
|
+
throw new TypeError("Invalid database watch page");
|
|
455
|
+
for (const item of value["items"]) assertDatabaseWatch(item);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function assertDatabaseWatchDeleted(value: unknown): asserts value is DatabaseWatchDeleted {
|
|
459
|
+
if (
|
|
460
|
+
!record(value) ||
|
|
461
|
+
!exact(value, ["id", "deleted"]) ||
|
|
462
|
+
!matches(value["id"], DATABASE_WATCH_ID_PATTERN) ||
|
|
463
|
+
value["deleted"] !== true
|
|
464
|
+
)
|
|
465
|
+
throw new TypeError("Invalid database watch deletion");
|
|
466
|
+
}
|
|
467
|
+
|
|
310
468
|
export function assertDatabaseWebhookCreated(
|
|
311
469
|
value: unknown,
|
|
312
470
|
): asserts value is DatabaseWebhookCreated {
|
|
@@ -358,7 +516,7 @@ export function assertDatabaseWebhookDelivery(
|
|
|
358
516
|
]) ||
|
|
359
517
|
!matches(value["id"], DELIVERY_ID_PATTERN) ||
|
|
360
518
|
!matches(value["event_id"], EVENT_ID_PATTERN) ||
|
|
361
|
-
!
|
|
519
|
+
!isDeliveredEventType(value["event_type"]) ||
|
|
362
520
|
!matches(value["webhook_id"], WEBHOOK_ID_PATTERN) ||
|
|
363
521
|
!oneOf(["pending", "delivered", "dead_lettered"] as const, value["state"]) ||
|
|
364
522
|
!nonNegativeInteger(value["attempts"]) ||
|
|
@@ -538,7 +696,7 @@ function assertWebhookFields(value: Record<string, unknown>): void {
|
|
|
538
696
|
!Array.isArray(value["event_types"]) ||
|
|
539
697
|
value["event_types"].length < 1 ||
|
|
540
698
|
value["event_types"].length > DATABASE_WEBHOOK_MAX_EVENT_TYPES ||
|
|
541
|
-
!value["event_types"].every(
|
|
699
|
+
!value["event_types"].every(isDatabaseWebhookEventPattern) ||
|
|
542
700
|
!Array.isArray(value["header_names"]) ||
|
|
543
701
|
value["header_names"].length > DATABASE_WEBHOOK_MAX_HEADERS ||
|
|
544
702
|
!value["header_names"].every((name) => matches(name, HEADER_NAME_PATTERN)) ||
|
|
@@ -561,16 +719,55 @@ function isHttpsUrl(value: unknown): value is string {
|
|
|
561
719
|
}
|
|
562
720
|
}
|
|
563
721
|
|
|
564
|
-
function
|
|
722
|
+
export function isDatabaseWebhookEventPattern(
|
|
723
|
+
value: unknown,
|
|
724
|
+
): value is DatabaseWebhookEventPattern {
|
|
725
|
+
return oneOf(DATABASE_WEBHOOK_EVENT_TYPES, value) || matches(value, DATA_SUBSCRIPTION_PATTERN);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** A delivery names one concrete event: a platform type or one data topic, never a wildcard. */
|
|
729
|
+
function isDeliveredEventType(
|
|
730
|
+
value: unknown,
|
|
731
|
+
): value is DatabaseWebhookEventType | DatabaseDataEventType {
|
|
732
|
+
return oneOf(DATABASE_WEBHOOK_EVENT_TYPES, value) || matches(value, DATA_EVENT_TYPE_PATTERN);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Event types of one webhook: 1 to 16 distinct patterns; a bare `*` is not among them. */
|
|
736
|
+
export function isDatabaseWebhookEventTypes(
|
|
737
|
+
value: unknown,
|
|
738
|
+
): value is readonly DatabaseWebhookEventPattern[] {
|
|
739
|
+
return (
|
|
740
|
+
Array.isArray(value) &&
|
|
741
|
+
value.length >= 1 &&
|
|
742
|
+
value.length <= DATABASE_WEBHOOK_MAX_EVENT_TYPES &&
|
|
743
|
+
value.every(isDatabaseWebhookEventPattern) &&
|
|
744
|
+
new Set(value).size === value.length
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function parseEventTypes(value: unknown): readonly DatabaseWebhookEventPattern[] {
|
|
749
|
+
if (!isDatabaseWebhookEventTypes(value))
|
|
750
|
+
throw new TypeError("Invalid database webhook event types");
|
|
751
|
+
return value;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function parseWatchOperations(value: unknown): readonly DatabaseWatchOperation[] {
|
|
755
|
+
if (value === undefined) return DATABASE_WATCH_OPERATIONS;
|
|
565
756
|
if (
|
|
566
757
|
!Array.isArray(value) ||
|
|
567
758
|
value.length < 1 ||
|
|
568
|
-
value.
|
|
569
|
-
!value.every((type) => oneOf(DATABASE_WEBHOOK_EVENT_TYPES, type)) ||
|
|
759
|
+
!value.every((operation) => oneOf(DATABASE_WATCH_OPERATIONS, operation)) ||
|
|
570
760
|
new Set(value).size !== value.length
|
|
571
761
|
)
|
|
572
|
-
throw new TypeError("Invalid database
|
|
573
|
-
return
|
|
762
|
+
throw new TypeError("Invalid database watch request");
|
|
763
|
+
return DATABASE_WATCH_OPERATIONS.filter((operation) => value.includes(operation));
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Operations are stored and asserted in the canonical order insert, update, delete. */
|
|
767
|
+
function isCanonicalOperations(value: unknown): value is DatabaseWatchOperation[] {
|
|
768
|
+
if (!Array.isArray(value) || value.length < 1) return false;
|
|
769
|
+
const canonical = DATABASE_WATCH_OPERATIONS.filter((operation) => value.includes(operation));
|
|
770
|
+
return canonical.length === value.length && canonical.every((op, index) => op === value[index]);
|
|
574
771
|
}
|
|
575
772
|
|
|
576
773
|
function parseHeaders(value: unknown): Record<string, string> {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { isDatabaseWatchTriggerName } from "./database-events.js";
|
|
1
2
|
import { DatabaseMigrationAdmissionError } from "./database-migration-errors.js";
|
|
3
|
+
import {
|
|
4
|
+
readMigrationCaptureTrigger,
|
|
5
|
+
type DatabaseMigrationCaptureTrigger,
|
|
6
|
+
} from "./database-migration-capture.js";
|
|
2
7
|
import {
|
|
3
8
|
readMigrationSql,
|
|
4
9
|
rejectMigrationSqlReferences,
|
|
@@ -11,6 +16,7 @@ import {
|
|
|
11
16
|
|
|
12
17
|
export { DatabaseMigrationAdmissionError } from "./database-migration-errors.js";
|
|
13
18
|
export type { DatabaseMigrationAdmissionReason } from "./database-migration-errors.js";
|
|
19
|
+
export type { DatabaseMigrationCaptureTrigger } from "./database-migration-capture.js";
|
|
14
20
|
export type { DatabaseMigrationRlsTarget } from "./database-migration-policies.js";
|
|
15
21
|
|
|
16
22
|
const MAX_MIGRATION_COUNT = 128;
|
|
@@ -68,6 +74,16 @@ export function admitExpandOnlyMigrations(
|
|
|
68
74
|
throw new DatabaseMigrationAdmissionError("invalid");
|
|
69
75
|
}
|
|
70
76
|
const statements = parseDatabaseMigrationStatements(sql);
|
|
77
|
+
// The watch service alone creates triggers under its reserved names, so the watch count stays exact.
|
|
78
|
+
if (
|
|
79
|
+
statements.some(
|
|
80
|
+
(statement) =>
|
|
81
|
+
statement.captureTrigger !== undefined &&
|
|
82
|
+
isDatabaseWatchTriggerName(statement.captureTrigger.trigger),
|
|
83
|
+
)
|
|
84
|
+
) {
|
|
85
|
+
throw new DatabaseMigrationAdmissionError("unsupported");
|
|
86
|
+
}
|
|
71
87
|
statementCount += statements.length;
|
|
72
88
|
return Object.freeze({ path: migration.path, statementCount: statements.length });
|
|
73
89
|
});
|
|
@@ -77,6 +93,7 @@ export function admitExpandOnlyMigrations(
|
|
|
77
93
|
export interface DatabaseMigrationStatement {
|
|
78
94
|
readonly sql: string;
|
|
79
95
|
readonly rlsTarget?: DatabaseMigrationRlsTarget;
|
|
96
|
+
readonly captureTrigger?: DatabaseMigrationCaptureTrigger;
|
|
80
97
|
}
|
|
81
98
|
|
|
82
99
|
export function parseDatabaseMigrationStatements(
|
|
@@ -94,6 +111,11 @@ export function parseDatabaseMigrationStatements(
|
|
|
94
111
|
if (statements.length === 0) throw new DatabaseMigrationAdmissionError("invalid");
|
|
95
112
|
return Object.freeze(
|
|
96
113
|
statements.map((statement) => {
|
|
114
|
+
const capture = readMigrationCaptureTrigger(statement.tokens);
|
|
115
|
+
if (capture !== undefined) {
|
|
116
|
+
rejectMigrationSqlReferences(capture.header);
|
|
117
|
+
return Object.freeze({ sql: statement.sql, captureTrigger: capture.target });
|
|
118
|
+
}
|
|
97
119
|
// Provider references are forbidden throughout executable policy SQL, while
|
|
98
120
|
// the native helper exception is narrowed by the policy reader below.
|
|
99
121
|
rejectMigrationSqlReferences(statement.tokens, true);
|