@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.
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  StderrDeviceAuthorizationPresenter,
35
35
  TokenRefreshRejectedError,
36
36
  UserTokenFileError,
37
+ assertDatabaseTableAnswer,
37
38
  assertVerifiedLogin,
38
39
  chooseLinkedProject,
39
40
  chooseWorkspace,
@@ -100,7 +101,7 @@ import {
100
101
  selectWorkspace,
101
102
  verifiedUserId,
102
103
  workspaceOf
103
- } from "./chunk-VJWPDFZ5.js";
104
+ } from "./chunk-A4ZYW4XW.js";
104
105
  import {
105
106
  CredentialStoreIntegrityError,
106
107
  CredentialStoreUnavailableError,
@@ -287,6 +288,7 @@ export {
287
288
  StderrDeviceAuthorizationPresenter,
288
289
  TokenRefreshRejectedError,
289
290
  UserTokenFileError,
291
+ assertDatabaseTableAnswer,
290
292
  assertVerifiedLogin,
291
293
  chooseLinkedProject,
292
294
  chooseWorkspace,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amerged/ohmyhost-cli",
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/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";
@@ -6,10 +6,17 @@ import {
6
6
  parseDatabaseListLimit,
7
7
  parseDatabaseScheduleName,
8
8
  parseDatabaseUsageWindow,
9
+ parseDatabaseWatchCreateRequest,
10
+ parseDatabaseWatchId,
9
11
  parseDatabaseWebhookCreateRequest,
10
12
  parseDatabaseWebhookId,
11
- type DatabaseWebhookEventType,
13
+ type DatabaseWatchRequest,
14
+ type DatabaseWebhookEventPattern,
12
15
  } from "@ohmyhost/contracts/database-events";
16
+ import {
17
+ DATABASE_TABLE_MAX_PRIMARY_KEY_COLUMNS,
18
+ isDatabaseTableIdentifier,
19
+ } from "@ohmyhost/contracts/database-tables";
13
20
  import {
14
21
  databaseSizeForMemory,
15
22
  type DatabaseComputeSelection,
@@ -404,12 +411,47 @@ export type ProductCliCommand =
404
411
  readonly yes: true;
405
412
  readonly credentialStore: "native";
406
413
  }
414
+ | {
415
+ readonly kind: "database-watch-create";
416
+ readonly projectId: string;
417
+ readonly environment: "dev" | "prod";
418
+ readonly request: DatabaseWatchRequest;
419
+ readonly idempotencyKey: string;
420
+ readonly yes: true;
421
+ readonly credentialStore: "native";
422
+ }
423
+ | {
424
+ readonly kind: "database-table-create";
425
+ readonly projectId: string;
426
+ readonly environment: "dev" | "prod";
427
+ readonly table: string;
428
+ readonly columnsFile: string;
429
+ readonly primaryKey: readonly string[];
430
+ readonly idempotencyKey: string;
431
+ readonly yes: true;
432
+ readonly credentialStore: "native";
433
+ }
434
+ | {
435
+ readonly kind: "database-watch-list";
436
+ readonly projectId: string;
437
+ readonly environment: "dev" | "prod";
438
+ readonly credentialStore: "native";
439
+ }
440
+ | {
441
+ readonly kind: "database-watch-delete";
442
+ readonly projectId: string;
443
+ readonly environment: "dev" | "prod";
444
+ readonly watchId: string;
445
+ readonly idempotencyKey: string;
446
+ readonly yes: true;
447
+ readonly credentialStore: "native";
448
+ }
407
449
  | {
408
450
  readonly kind: "database-webhook-create";
409
451
  readonly projectId: string;
410
452
  readonly environment: "dev" | "prod";
411
453
  readonly url: string;
412
- readonly eventTypes: readonly DatabaseWebhookEventType[];
454
+ readonly eventTypes: readonly DatabaseWebhookEventPattern[];
413
455
  readonly headersFile: string | null;
414
456
  readonly idempotencyKey: string;
415
457
  readonly yes: true;
@@ -1003,6 +1045,10 @@ export type RecognizedCommandLabel =
1003
1045
  | "database access create"
1004
1046
  | "database access list"
1005
1047
  | "database access revoke"
1048
+ | "database watch create"
1049
+ | "database watch list"
1050
+ | "database watch delete"
1051
+ | "database table create"
1006
1052
  | "database webhook create"
1007
1053
  | "database webhook list"
1008
1054
  | "database webhook get"
@@ -1174,6 +1220,13 @@ export const recognizedCommandLabel = (argv: readonly string[]): RecognizedComma
1174
1220
  if (values[1] === "list") return "database access list";
1175
1221
  if (values[1] === "revoke") return "database access revoke";
1176
1222
  }
1223
+ if (value === "database" && values[0] === "watch") {
1224
+ if (values[1] === "create") return "database watch create";
1225
+ if (values[1] === "list") return "database watch list";
1226
+ if (values[1] === "delete") return "database watch delete";
1227
+ }
1228
+ if (value === "database" && values[0] === "table" && values[1] === "create")
1229
+ return "database table create";
1177
1230
  if (value === "database" && values[0] === "webhook") {
1178
1231
  if (values[1] === "create") return "database webhook create";
1179
1232
  if (values[1] === "list") return "database webhook list";
@@ -2063,6 +2116,137 @@ export const parseProductCliCommand = (argv: readonly string[]): ProductCliComma
2063
2116
  }
2064
2117
  throw new InvalidCommandError();
2065
2118
  }
2119
+ if (first === "database" && values[0] === "watch") {
2120
+ values.shift();
2121
+ const subcommand = values.shift();
2122
+ if (subcommand === "create") {
2123
+ if (!yes || wait || follow || revoke || stdin) throw new InvalidCommandError();
2124
+ const options = parseOptionalOptions(values, [
2125
+ "--project",
2126
+ "--environment",
2127
+ "--table",
2128
+ "--schema",
2129
+ "--columns",
2130
+ "--operations",
2131
+ "--idempotency-key",
2132
+ ]);
2133
+ const projectId = options["--project"],
2134
+ environment = options["--environment"],
2135
+ table = options["--table"],
2136
+ schema = options["--schema"],
2137
+ columns = options["--columns"],
2138
+ operations = options["--operations"],
2139
+ idempotencyKey = options["--idempotency-key"];
2140
+ if (
2141
+ !isValue(projectId) ||
2142
+ !isValue(idempotencyKey) ||
2143
+ !isValue(table) ||
2144
+ !isValue(columns) ||
2145
+ (environment !== "dev" && environment !== "prod")
2146
+ )
2147
+ throw new InvalidCommandError();
2148
+ const request = contractValue(() =>
2149
+ parseDatabaseWatchCreateRequest({
2150
+ schema,
2151
+ table,
2152
+ columns: columns.split(","),
2153
+ ...(operations === undefined ? {} : { operations: operations.split(",") }),
2154
+ }),
2155
+ );
2156
+ return {
2157
+ kind: "database-watch-create",
2158
+ projectId,
2159
+ environment,
2160
+ request,
2161
+ idempotencyKey,
2162
+ yes: true,
2163
+ credentialStore,
2164
+ };
2165
+ }
2166
+ if (subcommand === "list") {
2167
+ if (yes || wait || follow || revoke || stdin) throw new InvalidCommandError();
2168
+ const options = parseOptionalOptions(values, ["--project", "--environment"]);
2169
+ const projectId = options["--project"],
2170
+ environment = options["--environment"] ?? "dev";
2171
+ if (!isValue(projectId) || (environment !== "dev" && environment !== "prod"))
2172
+ throw new InvalidCommandError();
2173
+ return { kind: "database-watch-list", projectId, environment, credentialStore };
2174
+ }
2175
+ if (subcommand === "delete") {
2176
+ if (!yes || wait || follow || revoke || stdin) throw new InvalidCommandError();
2177
+ const options = parseOptionalOptions(values, [
2178
+ "--project",
2179
+ "--environment",
2180
+ "--watch",
2181
+ "--idempotency-key",
2182
+ ]);
2183
+ const projectId = options["--project"],
2184
+ environment = options["--environment"],
2185
+ watchId = options["--watch"],
2186
+ idempotencyKey = options["--idempotency-key"];
2187
+ if (
2188
+ !isValue(projectId) ||
2189
+ !isValue(idempotencyKey) ||
2190
+ (environment !== "dev" && environment !== "prod")
2191
+ )
2192
+ throw new InvalidCommandError();
2193
+ return {
2194
+ kind: "database-watch-delete",
2195
+ projectId,
2196
+ environment,
2197
+ watchId: contractValue(() => parseDatabaseWatchId(watchId)),
2198
+ idempotencyKey,
2199
+ yes: true,
2200
+ credentialStore,
2201
+ };
2202
+ }
2203
+ throw new InvalidCommandError();
2204
+ }
2205
+ if (first === "database" && values[0] === "table") {
2206
+ values.shift();
2207
+ const subcommand = values.shift();
2208
+ if (subcommand === "create") {
2209
+ if (!yes || wait || follow || revoke || stdin) throw new InvalidCommandError();
2210
+ const options = parseOptionalOptions(values, [
2211
+ "--project",
2212
+ "--environment",
2213
+ "--table",
2214
+ "--columns-file",
2215
+ "--primary-key",
2216
+ "--idempotency-key",
2217
+ ]);
2218
+ const projectId = options["--project"],
2219
+ environment = options["--environment"],
2220
+ table = options["--table"],
2221
+ columnsFile = options["--columns-file"],
2222
+ primaryKey = options["--primary-key"],
2223
+ idempotencyKey = options["--idempotency-key"];
2224
+ const keyNames = primaryKey === undefined ? [] : primaryKey.split(",");
2225
+ if (
2226
+ !isValue(projectId) ||
2227
+ !isValue(idempotencyKey) ||
2228
+ !isValue(table) ||
2229
+ !isValue(columnsFile) ||
2230
+ (primaryKey !== undefined && !keyNames.every(isDatabaseTableIdentifier)) ||
2231
+ keyNames.length > DATABASE_TABLE_MAX_PRIMARY_KEY_COLUMNS ||
2232
+ !isDatabaseTableIdentifier(table) ||
2233
+ (environment !== "dev" && environment !== "prod")
2234
+ )
2235
+ throw new InvalidCommandError();
2236
+ return {
2237
+ kind: "database-table-create",
2238
+ projectId,
2239
+ environment,
2240
+ table,
2241
+ columnsFile,
2242
+ primaryKey: keyNames,
2243
+ idempotencyKey,
2244
+ yes: true,
2245
+ credentialStore,
2246
+ };
2247
+ }
2248
+ throw new InvalidCommandError();
2249
+ }
2066
2250
  if (first === "database" && values[0] === "webhook") {
2067
2251
  values.shift();
2068
2252
  const subcommand = values.shift();