@amerged/ohmyhost-cli 0.1.43 → 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 &&
@@ -34,12 +34,12 @@ const failures = {
34
34
  "Do not repeat the write with a new key. Inspect the target data with a read query and retain this operation ID. The same key only reads this receipt.",
35
35
  ],
36
36
  database_compute_failed: [
37
- "The database size change failed at the provider.",
38
- "Read the current database compute and report this operation ID through feedback. Preserve data; do not reset or redeploy the database to repair its size.",
37
+ "The database provider refused the size change or had no room for it. The database keeps its previous size and keeps running, and no credits are charged for the failed change.",
38
+ "Read the current size with 'ohmyhost database compute get --project PROJECT_ULID --environment dev|prod --json' or MCP database_compute_get, then repeat the change with a new idempotency key; the old key only returns this failure. If it fails again after a few minutes, start a new deployment of the project, repeat the change and report this operation ID through feedback. Never reset the database to change its size.",
39
39
  ],
40
40
  database_compute_plan_changed: [
41
- "Paid access ended before the database size change started.",
42
- "Read the organization's plan and current database compute, then confirm a new standard-size request. This operation did not resize the database.",
41
+ "Paid access ended before the database size change started, so it was not sent to the database provider. The database keeps its previous size and keeps running, and no credits are charged for the change.",
42
+ "Read the organization's plan with 'ohmyhost credits account --organization ORGANIZATION_ULID --json' or MCP organization_account_get. Sizes above 256 MiB need active Paid: ask the organization Owner to activate Paid through billing checkout, then read the current size with database compute get and repeat the change with a new idempotency key.",
43
43
  ],
44
44
  database_compute_rejected: [
45
45
  "The database size change could not be started.",
@@ -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.43",
3
+ "version": "0.1.45",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {