@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.
@@ -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 DatabaseWebhookEventType[];
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 DatabaseWebhookEventType[];
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 = /^evt_[0-9a-f]{20}$/u;
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
- !oneOf(DATABASE_WEBHOOK_EVENT_TYPES, value["event_type"]) ||
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((type) => oneOf(DATABASE_WEBHOOK_EVENT_TYPES, type)) ||
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 parseEventTypes(value: unknown): readonly DatabaseWebhookEventType[] {
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.length > DATABASE_WEBHOOK_MAX_EVENT_TYPES ||
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 webhook event types");
573
- return value as DatabaseWebhookEventType[];
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 or creation will not fix it.",
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: {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ohmyhost/sdk-ts",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -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,