@amerged/ohmyhost-cli 0.1.44 → 0.1.45
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-C6KEAOJP.js} +498 -7
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/apps/product-cli/package.json +1 -1
- package/src/apps/product-cli/src/cli.ts +61 -1
- package/src/apps/product-cli/src/command.ts +123 -2
- package/src/apps/product-cli/src/product-api.ts +86 -2
- 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/problem-policy.ts +36 -1
- package/src/packages/sdk-ts/package.json +1 -1
- package/src/packages/sdk-ts/src/generated/index.ts +25 -0
- package/src/packages/sdk-ts/src/generated/sdk.gen.ts +150 -0
- package/src/packages/sdk-ts/src/generated/types.gen.ts +266 -4
|
@@ -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);
|
|
@@ -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 &&
|
|
@@ -192,6 +192,41 @@ 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
|
+
},
|
|
195
230
|
project_notes_conflict: {
|
|
196
231
|
status: 409,
|
|
197
232
|
retryable: false,
|
|
@@ -502,7 +537,7 @@ export const PROBLEM_POLICY = {
|
|
|
502
537
|
status: 503,
|
|
503
538
|
retryable: false,
|
|
504
539
|
action:
|
|
505
|
-
"Report the platform user-key permission configuration through feedback. Repeated login
|
|
540
|
+
"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
541
|
title: "Token permissions unavailable",
|
|
507
542
|
},
|
|
508
543
|
reconciliation_exhausted: {
|
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
createOrganization,
|
|
24
24
|
createProject,
|
|
25
25
|
createProjectDatabaseAccess,
|
|
26
|
+
createProjectDatabaseWatch,
|
|
26
27
|
createProjectDatabaseWebhook,
|
|
27
28
|
createProjectDataChange,
|
|
28
29
|
createProjectDevAccessTicket,
|
|
@@ -35,6 +36,7 @@ export {
|
|
|
35
36
|
deletePaidProjectDomain,
|
|
36
37
|
deleteProject,
|
|
37
38
|
deleteProjectDatabaseSchedule,
|
|
39
|
+
deleteProjectDatabaseWatch,
|
|
38
40
|
deleteProjectDatabaseWebhook,
|
|
39
41
|
detachProjectMailDomainBinding,
|
|
40
42
|
diffManagedSource,
|
|
@@ -91,6 +93,7 @@ export {
|
|
|
91
93
|
listProjectDatabaseAccess,
|
|
92
94
|
listProjectDatabaseScheduleRuns,
|
|
93
95
|
listProjectDatabaseSchedules,
|
|
96
|
+
listProjectDatabaseWatches,
|
|
94
97
|
listProjectDatabaseWebhookDeliveries,
|
|
95
98
|
listProjectDatabaseWebhooks,
|
|
96
99
|
listProjects,
|
|
@@ -271,6 +274,11 @@ export type {
|
|
|
271
274
|
CreateProjectDatabaseAccessErrors,
|
|
272
275
|
CreateProjectDatabaseAccessResponse,
|
|
273
276
|
CreateProjectDatabaseAccessResponses,
|
|
277
|
+
CreateProjectDatabaseWatchData,
|
|
278
|
+
CreateProjectDatabaseWatchError,
|
|
279
|
+
CreateProjectDatabaseWatchErrors,
|
|
280
|
+
CreateProjectDatabaseWatchResponse,
|
|
281
|
+
CreateProjectDatabaseWatchResponses,
|
|
274
282
|
CreateProjectDatabaseWebhookData,
|
|
275
283
|
CreateProjectDatabaseWebhookError,
|
|
276
284
|
CreateProjectDatabaseWebhookErrors,
|
|
@@ -325,6 +333,11 @@ export type {
|
|
|
325
333
|
DatabaseScheduleRunAccepted,
|
|
326
334
|
DatabaseScheduleRunPage,
|
|
327
335
|
DatabaseScheduleSetRequest,
|
|
336
|
+
DatabaseWatch,
|
|
337
|
+
DatabaseWatchCreateRequest,
|
|
338
|
+
DatabaseWatchDeleted,
|
|
339
|
+
DatabaseWatchId,
|
|
340
|
+
DatabaseWatchPage,
|
|
328
341
|
DatabaseWebhook,
|
|
329
342
|
DatabaseWebhookCreated,
|
|
330
343
|
DatabaseWebhookCreateRequest,
|
|
@@ -332,6 +345,8 @@ export type {
|
|
|
332
345
|
DatabaseWebhookDelivery,
|
|
333
346
|
DatabaseWebhookDeliveryId,
|
|
334
347
|
DatabaseWebhookDeliveryPage,
|
|
348
|
+
DatabaseWebhookEventPattern,
|
|
349
|
+
DatabaseWebhookEventPatternWritable,
|
|
335
350
|
DatabaseWebhookEventType,
|
|
336
351
|
DatabaseWebhookId,
|
|
337
352
|
DatabaseWebhookPage,
|
|
@@ -364,6 +379,11 @@ export type {
|
|
|
364
379
|
DeleteProjectDatabaseScheduleErrors,
|
|
365
380
|
DeleteProjectDatabaseScheduleResponse,
|
|
366
381
|
DeleteProjectDatabaseScheduleResponses,
|
|
382
|
+
DeleteProjectDatabaseWatchData,
|
|
383
|
+
DeleteProjectDatabaseWatchError,
|
|
384
|
+
DeleteProjectDatabaseWatchErrors,
|
|
385
|
+
DeleteProjectDatabaseWatchResponse,
|
|
386
|
+
DeleteProjectDatabaseWatchResponses,
|
|
367
387
|
DeleteProjectDatabaseWebhookData,
|
|
368
388
|
DeleteProjectDatabaseWebhookError,
|
|
369
389
|
DeleteProjectDatabaseWebhookErrors,
|
|
@@ -685,6 +705,11 @@ export type {
|
|
|
685
705
|
ListProjectDatabaseSchedulesErrors,
|
|
686
706
|
ListProjectDatabaseSchedulesResponse,
|
|
687
707
|
ListProjectDatabaseSchedulesResponses,
|
|
708
|
+
ListProjectDatabaseWatchesData,
|
|
709
|
+
ListProjectDatabaseWatchesError,
|
|
710
|
+
ListProjectDatabaseWatchesErrors,
|
|
711
|
+
ListProjectDatabaseWatchesResponse,
|
|
712
|
+
ListProjectDatabaseWatchesResponses,
|
|
688
713
|
ListProjectDatabaseWebhookDeliveriesData,
|
|
689
714
|
ListProjectDatabaseWebhookDeliveriesError,
|
|
690
715
|
ListProjectDatabaseWebhookDeliveriesErrors,
|