@amerged/ohmyhost-cli 0.1.44 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,13 @@ import {
20
20
  type ProjectDatabaseWriteReceipt,
21
21
  } from "@ohmyhost/contracts/database-access";
22
22
  import { assertDatabaseCompute, type DatabaseCompute } from "@ohmyhost/contracts/database-compute";
23
+ import {
24
+ DATABASE_TABLE_COLUMN_TYPES,
25
+ DATABASE_TABLE_DEFAULTS,
26
+ isDatabaseTableIdentifier,
27
+ type DatabaseTableColumnType,
28
+ type DatabaseTableDefault,
29
+ } from "@ohmyhost/contracts/database-tables";
23
30
  import {
24
31
  assertDatabaseExecutionUsagePage,
25
32
  assertDatabaseSchedule,
@@ -33,6 +40,9 @@ import {
33
40
  assertDatabaseWebhookDeliveryPage,
34
41
  assertDatabaseWebhookPage,
35
42
  assertDatabaseWebhookReplayAccepted,
43
+ assertDatabaseWatch,
44
+ assertDatabaseWatchDeleted,
45
+ assertDatabaseWatchPage,
36
46
  assertDatabaseWebhookTest,
37
47
  type DatabaseExecutionUsagePage,
38
48
  type DatabaseSchedule,
@@ -45,7 +55,11 @@ import {
45
55
  type DatabaseWebhookCreated,
46
56
  type DatabaseWebhookDeleted,
47
57
  type DatabaseWebhookDeliveryPage,
48
- type DatabaseWebhookEventType,
58
+ type DatabaseWatch,
59
+ type DatabaseWatchDeleted,
60
+ type DatabaseWatchPage,
61
+ type DatabaseWatchRequest,
62
+ type DatabaseWebhookEventPattern,
49
63
  type DatabaseWebhookPage,
50
64
  type DatabaseWebhookReplayAccepted,
51
65
  type DatabaseWebhookTestResult,
@@ -152,6 +166,10 @@ import {
152
166
  getProjectDatabaseWebhook,
153
167
  deleteProjectDatabaseWebhook,
154
168
  testProjectDatabaseWebhook,
169
+ createProjectDatabaseWatch,
170
+ listProjectDatabaseWatches,
171
+ deleteProjectDatabaseWatch,
172
+ createProjectDatabaseTable,
155
173
  listProjectDatabaseWebhookDeliveries,
156
174
  replayProjectDatabaseWebhookDelivery,
157
175
  setProjectDatabaseSchedule,
@@ -205,6 +223,39 @@ export interface ProjectCreateInput {
205
223
  readonly idempotencyKey: string;
206
224
  }
207
225
 
226
+ /** One column of a table to create; nullable, identity and default are optional as the REST body allows. */
227
+ export interface DatabaseTableColumnInput {
228
+ readonly name: string;
229
+ readonly type: DatabaseTableColumnType;
230
+ readonly nullable?: boolean;
231
+ readonly identity?: boolean;
232
+ readonly default?: DatabaseTableDefault;
233
+ }
234
+
235
+ export interface DatabaseTableCreateInput {
236
+ readonly table: string;
237
+ readonly columns: readonly DatabaseTableColumnInput[];
238
+ readonly primaryKey: readonly string[];
239
+ }
240
+
241
+ /** The created or identical existing table, every column with its concrete nullability and default. */
242
+ export interface DatabaseTableAnswer {
243
+ readonly table: {
244
+ readonly environment: "dev" | "prod";
245
+ readonly schema: "public";
246
+ readonly table: string;
247
+ readonly columns: readonly {
248
+ readonly name: string;
249
+ readonly type: DatabaseTableColumnType;
250
+ readonly nullable: boolean;
251
+ readonly identity: boolean;
252
+ readonly default: DatabaseTableDefault | null;
253
+ }[];
254
+ readonly primary_key: readonly string[];
255
+ readonly created: boolean;
256
+ };
257
+ }
258
+
208
259
  export interface ProductApi {
209
260
  readonly managedSource?: ManagedSourceApi;
210
261
  planProjectDataChange(input: {
@@ -328,7 +379,7 @@ export interface ProductApi {
328
379
  readonly environment: "dev" | "prod";
329
380
  readonly idempotencyKey: string;
330
381
  readonly url: string;
331
- readonly eventTypes: readonly DatabaseWebhookEventType[];
382
+ readonly eventTypes: readonly DatabaseWebhookEventPattern[];
332
383
  readonly headers: Readonly<Record<string, string>>;
333
384
  }): Promise<DatabaseWebhookCreated>;
334
385
  listProjectDatabaseWebhooks(input: {
@@ -350,6 +401,28 @@ export interface ProductApi {
350
401
  readonly environment: "dev" | "prod";
351
402
  readonly webhookId: string;
352
403
  }): Promise<DatabaseWebhookTestResult>;
404
+ createProjectDatabaseWatch(input: {
405
+ readonly projectId: string;
406
+ readonly environment: "dev" | "prod";
407
+ readonly idempotencyKey: string;
408
+ readonly request: DatabaseWatchRequest;
409
+ }): Promise<DatabaseWatch>;
410
+ listProjectDatabaseWatches(input: {
411
+ readonly projectId: string;
412
+ readonly environment: "dev" | "prod";
413
+ }): Promise<DatabaseWatchPage>;
414
+ deleteProjectDatabaseWatch(input: {
415
+ readonly projectId: string;
416
+ readonly environment: "dev" | "prod";
417
+ readonly idempotencyKey: string;
418
+ readonly watchId: string;
419
+ }): Promise<DatabaseWatchDeleted>;
420
+ createProjectDatabaseTable(input: {
421
+ readonly projectId: string;
422
+ readonly environment: "dev" | "prod";
423
+ readonly idempotencyKey: string;
424
+ readonly request: DatabaseTableCreateInput;
425
+ }): Promise<DatabaseTableAnswer>;
353
426
  listProjectDatabaseWebhookDeliveries(input: {
354
427
  readonly projectId: string;
355
428
  readonly environment: "dev" | "prod";
@@ -566,9 +639,55 @@ export interface ProductApi {
566
639
 
567
640
  /** A webhook answer that names another environment, webhook or delivery than the request did. */
568
641
  const WEBHOOK_SCOPE_MISMATCH = "Webhook response scope mismatch";
642
+ const WATCH_SCOPE_MISMATCH = "Watch response scope mismatch";
643
+ const TABLE_SCOPE_MISMATCH = "Table response scope mismatch";
569
644
  /** A schedule answer that names another environment or schedule than the request did. */
570
645
  const SCHEDULE_SCOPE_MISMATCH = "Schedule response scope mismatch";
571
646
 
647
+ /** The REST answer read exactly: no extra keys, closed column types and defaults, keys among the columns. */
648
+ export function assertDatabaseTableAnswer(value: unknown): asserts value is DatabaseTableAnswer {
649
+ const table = isRecordValue(value) ? value["table"] : undefined;
650
+ const columns = isRecordValue(table) ? table["columns"] : undefined;
651
+ const primaryKey = isRecordValue(table) ? table["primary_key"] : undefined;
652
+ if (
653
+ !isRecordValue(table) ||
654
+ !hasExactKeys(table, ["environment", "schema", "table", "columns", "primary_key", "created"]) ||
655
+ (table["environment"] !== "dev" && table["environment"] !== "prod") ||
656
+ table["schema"] !== "public" ||
657
+ !isDatabaseTableIdentifier(table["table"]) ||
658
+ typeof table["created"] !== "boolean" ||
659
+ !Array.isArray(columns) ||
660
+ columns.length < 1 ||
661
+ !columns.every(isDatabaseTableAnswerColumn) ||
662
+ !Array.isArray(primaryKey) ||
663
+ !primaryKey.every((name: unknown) =>
664
+ columns.some((column: Record<string, unknown>) => column["name"] === name),
665
+ )
666
+ )
667
+ throw new TypeError("Invalid database table answer");
668
+ }
669
+
670
+ function isDatabaseTableAnswerColumn(value: unknown): boolean {
671
+ return (
672
+ isRecordValue(value) &&
673
+ hasExactKeys(value, ["name", "type", "nullable", "identity", "default"]) &&
674
+ isDatabaseTableIdentifier(value["name"]) &&
675
+ (DATABASE_TABLE_COLUMN_TYPES as readonly unknown[]).includes(value["type"]) &&
676
+ typeof value["nullable"] === "boolean" &&
677
+ typeof value["identity"] === "boolean" &&
678
+ (value["default"] === null ||
679
+ (DATABASE_TABLE_DEFAULTS as readonly unknown[]).includes(value["default"]))
680
+ );
681
+ }
682
+
683
+ function isRecordValue(value: unknown): value is Record<string, unknown> {
684
+ return typeof value === "object" && value !== null && !Array.isArray(value);
685
+ }
686
+
687
+ function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
688
+ return Object.keys(value).sort().join(",") === [...keys].sort().join(",");
689
+ }
690
+
572
691
  export class GeneratedSdkProductApi implements ProductApi {
573
692
  readonly #client: OhMyHostClient;
574
693
  public readonly managedSource: ManagedSourceApi;
@@ -889,6 +1008,87 @@ export class GeneratedSdkProductApi implements ProductApi {
889
1008
  return value;
890
1009
  }
891
1010
 
1011
+ public async createProjectDatabaseWatch(
1012
+ input: Parameters<ProductApi["createProjectDatabaseWatch"]>[0],
1013
+ ): Promise<DatabaseWatch> {
1014
+ const value = await createProjectDatabaseWatch(
1015
+ {
1016
+ project_id: input.projectId,
1017
+ "Idempotency-Key": input.idempotencyKey,
1018
+ databaseWatchCreateRequest: {
1019
+ environment: input.environment,
1020
+ schema: input.request.schema,
1021
+ table: input.request.table,
1022
+ columns: [...input.request.columns],
1023
+ operations: [...input.request.operations],
1024
+ },
1025
+ },
1026
+ { client: this.#client },
1027
+ );
1028
+ assertDatabaseWatch(value);
1029
+ if (
1030
+ value.environment !== input.environment ||
1031
+ value.schema !== input.request.schema ||
1032
+ value.table !== input.request.table
1033
+ )
1034
+ throw new TypeError(WATCH_SCOPE_MISMATCH);
1035
+ return value;
1036
+ }
1037
+
1038
+ public async listProjectDatabaseWatches(
1039
+ input: Parameters<ProductApi["listProjectDatabaseWatches"]>[0],
1040
+ ): Promise<DatabaseWatchPage> {
1041
+ const value = await listProjectDatabaseWatches(
1042
+ { project_id: input.projectId, environment: input.environment },
1043
+ { client: this.#client },
1044
+ );
1045
+ assertDatabaseWatchPage(value);
1046
+ if (value.items.some((item) => item.environment !== input.environment))
1047
+ throw new TypeError(WATCH_SCOPE_MISMATCH);
1048
+ return value;
1049
+ }
1050
+
1051
+ public async deleteProjectDatabaseWatch(
1052
+ input: Parameters<ProductApi["deleteProjectDatabaseWatch"]>[0],
1053
+ ): Promise<DatabaseWatchDeleted> {
1054
+ const value = await deleteProjectDatabaseWatch(
1055
+ {
1056
+ project_id: input.projectId,
1057
+ environment: input.environment,
1058
+ "Idempotency-Key": input.idempotencyKey,
1059
+ watch_id: input.watchId,
1060
+ },
1061
+ { client: this.#client },
1062
+ );
1063
+ assertDatabaseWatchDeleted(value);
1064
+ if (value.id !== input.watchId) throw new TypeError(WATCH_SCOPE_MISMATCH);
1065
+ return value;
1066
+ }
1067
+
1068
+ public async createProjectDatabaseTable(
1069
+ input: Parameters<ProductApi["createProjectDatabaseTable"]>[0],
1070
+ ): Promise<DatabaseTableAnswer> {
1071
+ const value = await createProjectDatabaseTable(
1072
+ {
1073
+ project_id: input.projectId,
1074
+ "Idempotency-Key": input.idempotencyKey,
1075
+ databaseTableCreateRequest: {
1076
+ environment: input.environment,
1077
+ table: input.request.table,
1078
+ columns: [...input.request.columns],
1079
+ ...(input.request.primaryKey.length === 0
1080
+ ? {}
1081
+ : { primary_key: [...input.request.primaryKey] }),
1082
+ },
1083
+ },
1084
+ { client: this.#client },
1085
+ );
1086
+ assertDatabaseTableAnswer(value);
1087
+ if (value.table.environment !== input.environment || value.table.table !== input.request.table)
1088
+ throw new TypeError(TABLE_SCOPE_MISMATCH);
1089
+ return value;
1090
+ }
1091
+
892
1092
  /** The signing secret exists only in this answer; the CLI prints it once and stores nothing. */
893
1093
  public async createProjectDatabaseWebhook(
894
1094
  input: Parameters<ProductApi["createProjectDatabaseWebhook"]>[0],
@@ -132,6 +132,11 @@
132
132
  "types": "./dist/database-events.d.ts",
133
133
  "import": "./dist/database-events.js"
134
134
  },
135
+ "./database-tables": {
136
+ "source": "./src/database-tables.ts",
137
+ "types": "./dist/database-tables.d.ts",
138
+ "import": "./dist/database-tables.js"
139
+ },
135
140
  "./functions": {
136
141
  "source": "./src/functions.ts",
137
142
  "types": "./dist/functions.d.ts",
@@ -11,6 +11,13 @@ export const DATABASE_WEBHOOK_EVENT_TYPES = [
11
11
  "database.deleted",
12
12
  ] as const;
13
13
  export type DatabaseWebhookEventType = (typeof DATABASE_WEBHOOK_EVENT_TYPES)[number];
14
+ /** Row-change events of a watched table; the topic is derived from the table name by dataEventTopic. */
15
+ export type DatabaseDataEventType = `data.${string}`;
16
+ export type DatabaseWebhookEventPattern = DatabaseWebhookEventType | DatabaseDataEventType;
17
+ export const DATABASE_WATCH_OPERATIONS = ["insert", "update", "delete"] as const;
18
+ export type DatabaseWatchOperation = (typeof DATABASE_WATCH_OPERATIONS)[number];
19
+ export const DATABASE_WATCH_MAX_COLUMNS = 32;
20
+ export const DATABASE_WATCH_DEFAULT_SCHEMA = "public";
14
21
  /** Per environment; the platform-wide provider cap is shared by every customer. */
15
22
  export const DATABASE_WEBHOOK_LIMIT = 5;
16
23
  export const DATABASE_WEBHOOK_MAX_EVENT_TYPES = 16;
@@ -66,9 +73,41 @@ export type DatabaseWebhookDeliveryState = "pending" | "delivered" | "dead_lette
66
73
  export type DatabaseWebhookDeliveryError = "timeout" | "network" | "http" | "expired" | "permanent";
67
74
  export type DatabaseWebhookTestError = "timeout" | "network" | "http";
68
75
 
76
+ export interface DatabaseWatchRequest {
77
+ readonly schema: string;
78
+ readonly table: string;
79
+ readonly columns: readonly string[];
80
+ readonly operations: readonly DatabaseWatchOperation[];
81
+ }
82
+
83
+ export interface DatabaseWatchTarget {
84
+ readonly schema: string;
85
+ readonly table: string;
86
+ }
87
+
88
+ export interface DatabaseWatch {
89
+ readonly id: string;
90
+ readonly environment: DatabaseEnvironment;
91
+ readonly schema: string;
92
+ readonly table: string;
93
+ readonly columns: readonly string[];
94
+ readonly operations: readonly DatabaseWatchOperation[];
95
+ readonly topic: string;
96
+ readonly event_type: DatabaseDataEventType;
97
+ }
98
+
99
+ export interface DatabaseWatchPage {
100
+ readonly items: readonly DatabaseWatch[];
101
+ }
102
+
103
+ export interface DatabaseWatchDeleted {
104
+ readonly id: string;
105
+ readonly deleted: true;
106
+ }
107
+
69
108
  export interface DatabaseWebhookCreateRequest {
70
109
  readonly url: string;
71
- readonly event_types: readonly 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);