@amerged/ohmyhost-mcp 0.1.44 → 0.1.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amerged/ohmyhost-mcp",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ohmyhost/mcp",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "private": true,
5
5
  "ohmyhost": {
6
6
  "deployment": "production",
@@ -20,6 +20,10 @@ import {
20
20
  assertDatabaseScheduleRunAccepted,
21
21
  assertDatabaseScheduleRunPage,
22
22
  assertDatabaseWebhookCreated,
23
+ assertDatabaseWatch,
24
+ assertDatabaseWatchDeleted,
25
+ assertDatabaseWatchPage,
26
+ DATABASE_WATCH_DEFAULT_SCHEMA,
23
27
  assertDatabaseWebhookDeleted,
24
28
  assertDatabaseWebhookDeliveryPage,
25
29
  assertDatabaseWebhookPage,
@@ -115,6 +119,10 @@ import {
115
119
  createProjectDatabaseWebhook,
116
120
  listProjectDatabaseWebhooks,
117
121
  deleteProjectDatabaseWebhook,
122
+ createProjectDatabaseTable,
123
+ createProjectDatabaseWatch,
124
+ listProjectDatabaseWatches,
125
+ deleteProjectDatabaseWatch,
118
126
  testProjectDatabaseWebhook,
119
127
  listProjectDatabaseWebhookDeliveries,
120
128
  replayProjectDatabaseWebhookDelivery,
@@ -177,6 +185,8 @@ import {
177
185
  type LocalProfile,
178
186
  type ProfileRegistry,
179
187
  type WorkspaceSession,
188
+ type DatabaseTableAnswer,
189
+ assertDatabaseTableAnswer,
180
190
  } from "@ohmyhost/product-cli";
181
191
 
182
192
  export class OperationEventsUnavailableError extends Error {
@@ -849,6 +859,83 @@ export class GeneratedSdkLocalMcpClient implements LocalMcpProductClient {
849
859
  requireScope(value.items.every((webhook) => webhook.environment === environment));
850
860
  return value;
851
861
  }
862
+ public async listDatabaseWatches(
863
+ input: Parameters<LocalMcpProductClient["listDatabaseWatches"]>[0],
864
+ ) {
865
+ const environment = input.environment ?? "dev";
866
+ const value = await listProjectDatabaseWatches(
867
+ { project_id: input.projectId, environment },
868
+ { client: this.#client },
869
+ );
870
+ assertDatabaseWatchPage(value);
871
+ requireScope(value.items.every((watch) => watch.environment === environment));
872
+ return value;
873
+ }
874
+ public async createDatabaseWatch(
875
+ input: Parameters<LocalMcpProductClient["createDatabaseWatch"]>[0],
876
+ ) {
877
+ const value = await createProjectDatabaseWatch(
878
+ {
879
+ project_id: input.projectId,
880
+ "Idempotency-Key": input.idempotencyKey,
881
+ databaseWatchCreateRequest: {
882
+ environment: input.environment,
883
+ ...(input.schema === undefined ? {} : { schema: input.schema }),
884
+ table: input.table,
885
+ columns: [...input.columns],
886
+ ...(input.operations === undefined ? {} : { operations: [...input.operations] }),
887
+ },
888
+ },
889
+ { client: this.#client },
890
+ );
891
+ assertDatabaseWatch(value);
892
+ requireScope(
893
+ value.environment === input.environment &&
894
+ value.table === input.table &&
895
+ value.schema === (input.schema ?? DATABASE_WATCH_DEFAULT_SCHEMA),
896
+ );
897
+ return value;
898
+ }
899
+ public async createDatabaseTable(
900
+ input: Parameters<LocalMcpProductClient["createDatabaseTable"]>[0],
901
+ ): Promise<DatabaseTableAnswer> {
902
+ const value = await createProjectDatabaseTable(
903
+ {
904
+ project_id: input.projectId,
905
+ "Idempotency-Key": input.idempotencyKey,
906
+ databaseTableCreateRequest: {
907
+ environment: input.environment,
908
+ table: input.table,
909
+ columns: [...input.columns],
910
+ ...(input.primaryKey === undefined || input.primaryKey.length === 0
911
+ ? {}
912
+ : { primary_key: [...input.primaryKey] }),
913
+ },
914
+ },
915
+ { client: this.#client },
916
+ );
917
+ assertDatabaseTableAnswer(value);
918
+ requireScope(
919
+ value.table.environment === input.environment && value.table.table === input.table,
920
+ );
921
+ return value;
922
+ }
923
+ public async deleteDatabaseWatch(
924
+ input: Parameters<LocalMcpProductClient["deleteDatabaseWatch"]>[0],
925
+ ) {
926
+ const value = await deleteProjectDatabaseWatch(
927
+ {
928
+ project_id: input.projectId,
929
+ environment: input.environment,
930
+ "Idempotency-Key": input.idempotencyKey,
931
+ watch_id: input.watchId,
932
+ },
933
+ { client: this.#client },
934
+ );
935
+ assertDatabaseWatchDeleted(value);
936
+ requireScope(value.id === input.watchId);
937
+ return value;
938
+ }
852
939
  /** The signing secret exists only in this response and is never stored by the client. */
853
940
  public async createDatabaseWebhook(
854
941
  input: Parameters<LocalMcpProductClient["createDatabaseWebhook"]>[0],
@@ -8,16 +8,22 @@ import {
8
8
  DATABASE_SCHEDULE_MIN_EVERY_SECONDS,
9
9
  DATABASE_SCHEDULE_MIN_TIMEOUT_MS,
10
10
  DATABASE_SCHEDULE_STATEMENT_MAX_BYTES,
11
+ DATABASE_WATCH_OPERATIONS,
12
+ isDatabaseWatchSchema,
13
+ isDatabaseWebhookEventPattern,
11
14
  type DatabaseExecutionUsagePage,
12
15
  type DatabaseSchedule,
13
16
  type DatabaseSchedulePage,
14
17
  type DatabaseScheduleDeleted,
15
18
  type DatabaseScheduleRunAccepted,
16
19
  type DatabaseScheduleRunPage,
20
+ type DatabaseWatch,
21
+ type DatabaseWatchDeleted,
22
+ type DatabaseWatchPage,
17
23
  type DatabaseWebhookCreated,
18
24
  type DatabaseWebhookDeleted,
19
25
  type DatabaseWebhookDeliveryPage,
20
- type DatabaseWebhookEventType,
26
+ type DatabaseWebhookEventPattern,
21
27
  type DatabaseWebhookPage,
22
28
  type DatabaseWebhookReplayAccepted,
23
29
  type DatabaseWebhookTestResult,
@@ -76,6 +82,15 @@ import {
76
82
  type ProfileRequestInput,
77
83
  } from "@ohmyhost/product-cli";
78
84
  import { createOhmyhostMcpServer } from "./index.js";
85
+ import type { DatabaseTableAnswer, DatabaseTableColumnInput } from "@ohmyhost/product-cli";
86
+ import {
87
+ DATABASE_TABLE_COLUMN_TYPES,
88
+ DATABASE_TABLE_DEFAULTS,
89
+ DATABASE_TABLE_MAX_COLUMNS,
90
+ DATABASE_TABLE_MAX_PRIMARY_KEY_COLUMNS,
91
+ isDatabaseTableIdentifier,
92
+ parseDatabaseTableCreateRequest,
93
+ } from "@ohmyhost/contracts/database-tables";
79
94
 
80
95
  import type {
81
96
  CurrentIdentity,
@@ -323,11 +338,38 @@ export interface LocalMcpProductClient {
323
338
  readonly projectId: string;
324
339
  readonly environment?: DatabaseEnvironment;
325
340
  }): Promise<DatabaseWebhookPage>;
341
+ createDatabaseTable(input: {
342
+ readonly projectId: string;
343
+ readonly environment: DatabaseEnvironment;
344
+ readonly table: string;
345
+ readonly columns: readonly DatabaseTableColumnInput[];
346
+ readonly primaryKey?: readonly string[];
347
+ readonly idempotencyKey: string;
348
+ }): Promise<DatabaseTableAnswer>;
349
+ listDatabaseWatches(input: {
350
+ readonly projectId: string;
351
+ readonly environment?: DatabaseEnvironment;
352
+ }): Promise<DatabaseWatchPage>;
353
+ createDatabaseWatch(input: {
354
+ readonly projectId: string;
355
+ readonly environment: DatabaseEnvironment;
356
+ readonly schema?: string;
357
+ readonly table: string;
358
+ readonly columns: readonly string[];
359
+ readonly operations?: readonly (typeof DATABASE_WATCH_OPERATIONS)[number][];
360
+ readonly idempotencyKey: string;
361
+ }): Promise<DatabaseWatch>;
362
+ deleteDatabaseWatch(input: {
363
+ readonly projectId: string;
364
+ readonly environment: DatabaseEnvironment;
365
+ readonly watchId: string;
366
+ readonly idempotencyKey: string;
367
+ }): Promise<DatabaseWatchDeleted>;
326
368
  createDatabaseWebhook(input: {
327
369
  readonly projectId: string;
328
370
  readonly environment: DatabaseEnvironment;
329
371
  readonly url: string;
330
- readonly eventTypes: readonly DatabaseWebhookEventType[];
372
+ readonly eventTypes: readonly DatabaseWebhookEventPattern[];
331
373
  readonly headers: Readonly<Record<string, string>>;
332
374
  readonly idempotencyKey: string;
333
375
  }): Promise<DatabaseWebhookCreated>;
@@ -539,7 +581,10 @@ interface ProjectDeleteInput {
539
581
  const identifier = z.string().min(1).max(256);
540
582
  const databaseEnvironment = z.enum(["dev", "prod"]);
541
583
  const databaseWebhookId = z.string().regex(/^whk_[a-z0-9]{20}$/u);
584
+ const databaseWatchId = z.string().regex(/^pgcf_watch_[0-9a-f]{8}$/u);
585
+ const databaseIdentifier = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,62}$/u);
542
586
  const databaseDeliveryId = z.string().regex(/^dlv_[0-9a-f]{20}$/u);
587
+ const databaseTableIdentifier = z.string().refine(isDatabaseTableIdentifier);
543
588
  const databaseScheduleName = z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/u);
544
589
  /** Every product tool accepts the saved login to act as; it is an alias, never a credential. */
545
590
  const profileName = z
@@ -1122,6 +1167,122 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1122
1167
  client.revokeDatabaseAccess({ projectId: input.project_id, accessId: input.access_id }),
1123
1168
  destructiveAnnotations(),
1124
1169
  );
1170
+ registerProductTool(
1171
+ server,
1172
+ dependencies,
1173
+ "database_watch_list",
1174
+ "List this project's database watches of one Dev or Prod environment (dev when omitted). Each watch shows its table, columns, operations and the event type data.<topic> it publishes. Listing wakes a sleeping database.",
1175
+ z.object({ project_id: identifier, environment: databaseEnvironment.optional() }),
1176
+ (client, input) =>
1177
+ client.listDatabaseWatches({
1178
+ projectId: input.project_id,
1179
+ ...(input.environment === undefined ? {} : { environment: input.environment }),
1180
+ }),
1181
+ );
1182
+ registerProductTool(
1183
+ server,
1184
+ dependencies,
1185
+ "database_watch_create",
1186
+ "Publish row changes of one table of a Dev or Prod database as data events: only the listed columns (at most 32) of each insert, update or delete are published, with the event type data.<topic> returned by this call. Pass that event type to database_webhook_create. The table must exist as an ordinary table of the application, created by a deployed migration; a sleeping database wakes. Rows written before the watch exists are not published, and a published column must not be dropped while the watch exists. Requires confirmed: true and an idempotency key. Refused with project_budget_exceeded while the Stop budget is reached.",
1187
+ z.object({
1188
+ project_id: identifier,
1189
+ environment: databaseEnvironment,
1190
+ schema: databaseIdentifier.refine(isDatabaseWatchSchema).optional(),
1191
+ table: databaseIdentifier,
1192
+ columns: z
1193
+ .array(databaseIdentifier)
1194
+ .min(1)
1195
+ .max(32)
1196
+ .refine((columns) => new Set(columns).size === columns.length),
1197
+ operations: z
1198
+ .array(z.enum(DATABASE_WATCH_OPERATIONS))
1199
+ .min(1)
1200
+ .max(3)
1201
+ .refine((operations) => new Set(operations).size === operations.length)
1202
+ .optional(),
1203
+ idempotency_key: idempotencyKey,
1204
+ confirmed: z.literal(true),
1205
+ }),
1206
+ (client, input) =>
1207
+ client.createDatabaseWatch({
1208
+ projectId: input.project_id,
1209
+ environment: input.environment,
1210
+ ...(input.schema === undefined ? {} : { schema: input.schema }),
1211
+ table: input.table,
1212
+ columns: input.columns,
1213
+ ...(input.operations === undefined ? {} : { operations: input.operations }),
1214
+ idempotencyKey: input.idempotency_key,
1215
+ }),
1216
+ mutationAnnotations(),
1217
+ );
1218
+ registerProductTool(
1219
+ server,
1220
+ dependencies,
1221
+ "database_watch_delete",
1222
+ "Remove the capture trigger of one database watch: its table publishes no more data events. Destructive: requires confirmed: true and an idempotency key. Deleting an already deleted watch answers not found.",
1223
+ z.object({
1224
+ project_id: identifier,
1225
+ environment: databaseEnvironment,
1226
+ watch_id: databaseWatchId,
1227
+ idempotency_key: idempotencyKey,
1228
+ confirmed: z.literal(true),
1229
+ }),
1230
+ (client, input) =>
1231
+ client.deleteDatabaseWatch({
1232
+ projectId: input.project_id,
1233
+ environment: input.environment,
1234
+ watchId: input.watch_id,
1235
+ idempotencyKey: input.idempotency_key,
1236
+ }),
1237
+ destructiveAnnotations(),
1238
+ );
1239
+ registerProductTool(
1240
+ server,
1241
+ dependencies,
1242
+ "database_table_create",
1243
+ "Create one ordinary table in the public schema of a Dev or Prod database, as the migration role with the same access as migration tables, so a database_watch_create can attach later. Each column has a lower-case name, a type (text, integer, bigint, boolean, numeric, double precision, timestamptz, date, uuid or jsonb) and optional nullable, identity (integer or bigint only) and default (now for timestamptz, uuid for uuid); at most 64 columns, 8 primary-key columns and 100 tables per database. An identical repeat answers created: false; a table of the same name with another definition is refused with database_table_conflict. Tables created this way are not in the repository's migration files: record them in project notes and add a migration when the source should reproduce the database. Requires confirmed: true and an idempotency key. Refused with project_budget_exceeded while the Stop budget is reached.",
1244
+ z.object({
1245
+ project_id: identifier,
1246
+ environment: databaseEnvironment,
1247
+ table: databaseTableIdentifier,
1248
+ columns: z
1249
+ .array(
1250
+ z.object({
1251
+ name: databaseTableIdentifier,
1252
+ type: z.enum(DATABASE_TABLE_COLUMN_TYPES),
1253
+ nullable: z.boolean().optional(),
1254
+ identity: z.boolean().optional(),
1255
+ default: z.enum(DATABASE_TABLE_DEFAULTS).optional(),
1256
+ }),
1257
+ )
1258
+ .min(1)
1259
+ .max(DATABASE_TABLE_MAX_COLUMNS),
1260
+ primary_key: z
1261
+ .array(databaseTableIdentifier)
1262
+ .min(1)
1263
+ .max(DATABASE_TABLE_MAX_PRIMARY_KEY_COLUMNS)
1264
+ .optional(),
1265
+ idempotency_key: idempotencyKey,
1266
+ confirmed: z.literal(true),
1267
+ }),
1268
+ (client, input) => {
1269
+ // The contract checks the column types, identity and defaults, unique names and key columns before any call.
1270
+ const request = parseDatabaseTableCreateRequest({
1271
+ table: input.table,
1272
+ columns: input.columns,
1273
+ ...(input.primary_key === undefined ? {} : { primaryKey: input.primary_key }),
1274
+ });
1275
+ return client.createDatabaseTable({
1276
+ projectId: input.project_id,
1277
+ environment: input.environment,
1278
+ table: request.table,
1279
+ columns: input.columns,
1280
+ ...(request.primaryKey.length === 0 ? {} : { primaryKey: request.primaryKey }),
1281
+ idempotencyKey: input.idempotency_key,
1282
+ });
1283
+ },
1284
+ mutationAnnotations(),
1285
+ );
1125
1286
  registerProductTool(
1126
1287
  server,
1127
1288
  dependencies,
@@ -1138,7 +1299,7 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1138
1299
  server,
1139
1300
  dependencies,
1140
1301
  "database_webhook_create",
1141
- "Subscribe one HTTPS receiver to database lifecycle events (database.created, ready, woke, slept, suspended, resized, deleted) of one Dev or Prod database. The signing secret is RETURNED EXACTLY ONCE: store it in a secret manager and never write it into files, notes, source, commit messages or later messages. Deliveries are signed, retried and dead-lettered when retries stop. At most five webhooks exist per project environment. Requires confirmed: true and an idempotency key; the same key with the same request returns the stored answer.",
1302
+ "Subscribe one HTTPS receiver to database lifecycle events (database.created, ready, woke, slept, suspended, resized, deleted) of one Dev or Prod database, or to data events: data.<topic> for the rows of one watched table (the event type database_watch_create returns) or data.* for every watched table; a bare * never matches data events. The signing secret is RETURNED EXACTLY ONCE: store it in a secret manager and never write it into files, notes, source, commit messages or later messages. Deliveries are signed, retried and dead-lettered when retries stop. At most five webhooks exist per project environment. Requires confirmed: true and an idempotency key; the same key with the same request returns the stored answer.",
1142
1303
  z.object({
1143
1304
  project_id: identifier,
1144
1305
  environment: databaseEnvironment,
@@ -1146,7 +1307,18 @@ export function createLocalOhmyhostMcpServer(dependencies: LocalOhmyhostMcpDepen
1146
1307
  .string()
1147
1308
  .max(2048)
1148
1309
  .regex(/^https:\/\//u),
1149
- event_types: z.array(z.enum(DATABASE_WEBHOOK_EVENT_TYPES)).min(1).max(16),
1310
+ event_types: z
1311
+ .array(
1312
+ z.union([
1313
+ z.enum(DATABASE_WEBHOOK_EVENT_TYPES),
1314
+ z
1315
+ .string()
1316
+ .regex(/^data\.(?:\*|[a-z][a-z0-9_]{0,47})$/u)
1317
+ .refine(isDatabaseWebhookEventPattern),
1318
+ ]),
1319
+ )
1320
+ .min(1)
1321
+ .max(16),
1150
1322
  headers: z.record(z.string(), z.string()).optional(),
1151
1323
  idempotency_key: idempotencyKey,
1152
1324
  confirmed: z.literal(true),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ohmyhost/product-cli",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "private": true,
5
5
  "ohmyhost": {
6
6
  "deployment": "production",
@@ -68,7 +68,15 @@ import {
68
68
  type CurrentCredential,
69
69
  type StoredCredential,
70
70
  } from "./credential-store.js";
71
- import type { ProductApi } from "./product-api.js";
71
+ import type {
72
+ DatabaseTableColumnInput,
73
+ DatabaseTableCreateInput,
74
+ ProductApi,
75
+ } from "./product-api.js";
76
+ import {
77
+ parseDatabaseTableCreateRequest,
78
+ type DatabaseTableColumn,
79
+ } from "@ohmyhost/contracts/database-tables";
72
80
  import {
73
81
  defaultProfileName,
74
82
  isProfileUserId,
@@ -419,8 +427,16 @@ const HELP_USAGE = Object.freeze({
419
427
  "ohmyhost database access list --project ULID [--environment dev|prod] --json",
420
428
  "database access revoke":
421
429
  "ohmyhost database access revoke --project ULID --access ULID --yes --json",
430
+ "database watch create":
431
+ "ohmyhost database watch create --project ULID --environment dev|prod --table NAME [--schema public] --columns COL[,COL] [--operations insert,update,delete] --idempotency-key KEY --yes --json (publishes only the listed columns, at most 32, of each row change as the event type data.<topic> printed in this answer; the table must exist as an ordinary table of the application; a sleeping database wakes; next, subscribe with 'ohmyhost database webhook create ... --event-types data.<topic>')",
432
+ "database watch list":
433
+ "ohmyhost database watch list --project ULID [--environment dev|prod] --json (dev when omitted; a sleeping database wakes)",
434
+ "database watch delete":
435
+ "ohmyhost database watch delete --project ULID --environment dev|prod --watch ID --idempotency-key KEY --yes --json",
436
+ "database table create":
437
+ "ohmyhost database table create --project ULID --environment dev|prod --table NAME --columns-file PATH [--primary-key COL[,COL]] --idempotency-key KEY --yes --json (the table is created in the public schema as the migration role, with the same access as migration tables, so a database watch can attach later; the columns file is a UTF-8 JSON array of 1 to 64 column objects, at most 64 KiB, each with name, type and optional nullable, identity and default; types: text, integer, bigint, boolean, numeric, double precision, timestamptz, date, uuid, jsonb; identity needs integer or bigint; default now needs timestamptz and default uuid needs uuid; identical repeats answer created false; at most 100 tables, 64 columns per table)",
422
438
  "database webhook create":
423
- "ohmyhost database webhook create --project ULID --environment dev|prod --url HTTPS_URL --event-types TYPE[,TYPE] [--headers-file PATH] --idempotency-key KEY --yes --json (the signing secret is shown once in this answer; types: database.created, database.ready, database.woke, database.slept, database.suspended, database.resized, database.deleted; the headers file is a UTF-8 JSON object of at most 8 KiB)",
439
+ "ohmyhost database webhook create --project ULID --environment dev|prod --url HTTPS_URL --event-types TYPE[,TYPE] [--headers-file PATH] --idempotency-key KEY --yes --json (the signing secret is shown once in this answer; types: database.created, database.ready, database.woke, database.slept, database.suspended, database.resized, database.deleted, data.<topic> for the rows of one watched table printed by database watch create, or data.* for every watched table; a bare * is not accepted and does not include data events; the headers file is a UTF-8 JSON object of at most 8 KiB)",
424
440
  "database webhook list":
425
441
  "ohmyhost database webhook list --project ULID [--environment dev|prod] --json (dev when omitted; the secret is never listed)",
426
442
  "database webhook get":
@@ -659,6 +675,9 @@ const SCHEDULE_STATEMENTS_FILE_INVALID_ACTION =
659
675
  const SCHEDULE_INVALID_ACTION =
660
676
  "Check the schedule request: --cron takes five fields in UTC (9 to 120 characters), or --every-seconds takes 60 to 2678400, never both; --timeout-ms takes 1000 to 30000; the statements file must hold 1 to 20 non-blank SQL strings of at most 16 KiB each and 64 KiB in total. Nothing was saved; fix it and run the command again.";
661
677
 
678
+ const COLUMNS_FILE_INVALID_ACTION =
679
+ "Put one JSON array of 1 to 64 column objects in the file named by --columns-file: UTF-8, at most 64 KiB, each with a lower-case name, a type (text, integer, bigint, boolean, numeric, double precision, timestamptz, date, uuid or jsonb) and optional nullable, identity (integer or bigint only) and default (now for timestamptz, uuid for uuid). Each --primary-key column must be one of them, at most 8 names. Nothing was created; fix the file or the flags and run the command again.";
680
+
662
681
  const STATEMENT_FILE_INVALID_ACTION =
663
682
  "Put one INSERT, UPDATE or DELETE statement that starts with that keyword (no leading comment or WITH), UTF-8, at most 64 KiB, in the file named by --statement-file, then rerun with the same --idempotency-key.";
664
683
 
@@ -1191,6 +1210,14 @@ export class ProductCli {
1191
1210
  return await this.listDatabaseAccess(command, signal);
1192
1211
  if (command.kind === "database-access-revoke")
1193
1212
  return await this.revokeDatabaseAccess(command, signal);
1213
+ if (command.kind === "database-watch-create")
1214
+ return await this.createDatabaseWatch(command, signal);
1215
+ if (command.kind === "database-watch-list")
1216
+ return await this.listDatabaseWatches(command, signal);
1217
+ if (command.kind === "database-watch-delete")
1218
+ return await this.deleteDatabaseWatch(command, signal);
1219
+ if (command.kind === "database-table-create")
1220
+ return await this.createDatabaseTable(command, signal);
1194
1221
  if (command.kind === "database-webhook-create")
1195
1222
  return await this.createDatabaseWebhook(command, signal);
1196
1223
  if (command.kind === "database-webhook-list")
@@ -1952,6 +1979,67 @@ export class ProductCli {
1952
1979
  return success("database access revoke", { status: "succeeded", access });
1953
1980
  }
1954
1981
 
1982
+ /** The answer names the event type to subscribe to; the watch already carries the columns and the topic. */
1983
+ private async createDatabaseWatch(
1984
+ command: Extract<ProductCliCommand, { kind: "database-watch-create" }>,
1985
+ signal: AbortSignal,
1986
+ ): Promise<CliResult> {
1987
+ const api = await this.authenticatedApi(command.credentialStore, signal);
1988
+ const watch = await api.createProjectDatabaseWatch({
1989
+ projectId: command.projectId,
1990
+ environment: command.environment,
1991
+ idempotencyKey: command.idempotencyKey,
1992
+ request: command.request,
1993
+ });
1994
+ return success("database watch create", {
1995
+ status: "succeeded",
1996
+ watch,
1997
+ next_action: `Run '${this.dependencies.commandPrefix} database webhook create --project ${command.projectId} --environment ${command.environment} --url HTTPS_URL --event-types ${watch.event_type} --idempotency-key KEY --yes --json' to receive ${watch.event_type} events at an HTTPS receiver.`,
1998
+ });
1999
+ }
2000
+
2001
+ private async listDatabaseWatches(
2002
+ command: Extract<ProductCliCommand, { kind: "database-watch-list" }>,
2003
+ signal: AbortSignal,
2004
+ ): Promise<CliResult> {
2005
+ const api = await this.authenticatedApi(command.credentialStore, signal);
2006
+ const page = await api.listProjectDatabaseWatches({
2007
+ projectId: command.projectId,
2008
+ environment: command.environment,
2009
+ });
2010
+ return success("database watch list", { status: "succeeded", watch: page.items });
2011
+ }
2012
+
2013
+ private async deleteDatabaseWatch(
2014
+ command: Extract<ProductCliCommand, { kind: "database-watch-delete" }>,
2015
+ signal: AbortSignal,
2016
+ ): Promise<CliResult> {
2017
+ const api = await this.authenticatedApi(command.credentialStore, signal);
2018
+ const deleted = await api.deleteProjectDatabaseWatch({
2019
+ projectId: command.projectId,
2020
+ environment: command.environment,
2021
+ idempotencyKey: command.idempotencyKey,
2022
+ watchId: command.watchId,
2023
+ });
2024
+ return success("database watch delete", { status: "succeeded", deleted });
2025
+ }
2026
+
2027
+ /** The columns file and the key are checked before any request; a repeat answers the same table with created false. */
2028
+ private async createDatabaseTable(
2029
+ command: Extract<ProductCliCommand, { kind: "database-table-create" }>,
2030
+ signal: AbortSignal,
2031
+ ): Promise<CliResult> {
2032
+ const request = await tableCreateRequest(command);
2033
+ const api = await this.authenticatedApi(command.credentialStore, signal);
2034
+ const answer = await api.createProjectDatabaseTable({
2035
+ projectId: command.projectId,
2036
+ environment: command.environment,
2037
+ idempotencyKey: command.idempotencyKey,
2038
+ request,
2039
+ });
2040
+ return success("database table create", { status: "succeeded", table: answer.table });
2041
+ }
2042
+
1955
2043
  /** The signing secret is returned by create only; every other answer carries no secret. */
1956
2044
  private async createDatabaseWebhook(
1957
2045
  command: Extract<ProductCliCommand, { kind: "database-webhook-create" }>,
@@ -3422,6 +3510,49 @@ const scheduleSetRequest = async (
3422
3510
  }
3423
3511
  };
3424
3512
 
3513
+ /**
3514
+ * The table of `database table create`: the columns file is a UTF-8 JSON array, and the table, the
3515
+ * columns and the key are checked by the contract the service uses. A refused file sends nothing.
3516
+ */
3517
+ const tableCreateRequest = async (
3518
+ command: Extract<ProductCliCommand, { kind: "database-table-create" }>,
3519
+ ): Promise<DatabaseTableCreateInput> => {
3520
+ const columnsFailure = () =>
3521
+ new ProductCliFailure(2, "columns_file_invalid", false, COLUMNS_FILE_INVALID_ACTION);
3522
+ const columns = await readBoundedUtf8File(command.columnsFile, 65_536, columnsFailure).then(
3523
+ (text: string): unknown => {
3524
+ try {
3525
+ return JSON.parse(text) as unknown;
3526
+ } catch {
3527
+ throw columnsFailure();
3528
+ }
3529
+ },
3530
+ );
3531
+ try {
3532
+ const parsed = parseDatabaseTableCreateRequest({
3533
+ table: command.table,
3534
+ columns,
3535
+ ...(command.primaryKey.length === 0 ? {} : { primaryKey: command.primaryKey }),
3536
+ });
3537
+ return {
3538
+ table: parsed.table,
3539
+ columns: parsed.columns.map(tableColumnInput),
3540
+ primaryKey: parsed.primaryKey,
3541
+ };
3542
+ } catch {
3543
+ throw columnsFailure();
3544
+ }
3545
+ };
3546
+
3547
+ /** The checked column as the REST body names it; a default that is not set is left out, not sent as null. */
3548
+ const tableColumnInput = (column: DatabaseTableColumn): DatabaseTableColumnInput => ({
3549
+ name: column.name,
3550
+ type: column.type,
3551
+ nullable: column.nullable,
3552
+ identity: column.identity,
3553
+ ...(column.default === null ? {} : { default: column.default }),
3554
+ });
3555
+
3425
3556
  const psqlUnavailable = (): CliResult =>
3426
3557
  failure(
3427
3558
  "database psql",
@@ -3526,6 +3657,10 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
3526
3657
  if (command.kind === "database-access-list") return "database access list";
3527
3658
  if (command.kind === "database-access-revoke") return "database access revoke";
3528
3659
  if (command.kind === "database-psql") return "database psql";
3660
+ if (command.kind === "database-watch-create") return "database watch create";
3661
+ if (command.kind === "database-watch-list") return "database watch list";
3662
+ if (command.kind === "database-watch-delete") return "database watch delete";
3663
+ if (command.kind === "database-table-create") return "database table create";
3529
3664
  if (command.kind === "database-webhook-create") return "database webhook create";
3530
3665
  if (command.kind === "database-webhook-list") return "database webhook list";
3531
3666
  if (command.kind === "database-webhook-get") return "database webhook get";