@amerged/ohmyhost-cli 0.1.45 → 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-C6KEAOJP.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.45",
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.45",
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,
@@ -425,6 +433,8 @@ const HELP_USAGE = Object.freeze({
425
433
  "ohmyhost database watch list --project ULID [--environment dev|prod] --json (dev when omitted; a sleeping database wakes)",
426
434
  "database watch delete":
427
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)",
428
438
  "database webhook create":
429
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)",
430
440
  "database webhook list":
@@ -665,6 +675,9 @@ const SCHEDULE_STATEMENTS_FILE_INVALID_ACTION =
665
675
  const SCHEDULE_INVALID_ACTION =
666
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.";
667
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
+
668
681
  const STATEMENT_FILE_INVALID_ACTION =
669
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.";
670
683
 
@@ -1203,6 +1216,8 @@ export class ProductCli {
1203
1216
  return await this.listDatabaseWatches(command, signal);
1204
1217
  if (command.kind === "database-watch-delete")
1205
1218
  return await this.deleteDatabaseWatch(command, signal);
1219
+ if (command.kind === "database-table-create")
1220
+ return await this.createDatabaseTable(command, signal);
1206
1221
  if (command.kind === "database-webhook-create")
1207
1222
  return await this.createDatabaseWebhook(command, signal);
1208
1223
  if (command.kind === "database-webhook-list")
@@ -2009,6 +2024,22 @@ export class ProductCli {
2009
2024
  return success("database watch delete", { status: "succeeded", deleted });
2010
2025
  }
2011
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
+
2012
2043
  /** The signing secret is returned by create only; every other answer carries no secret. */
2013
2044
  private async createDatabaseWebhook(
2014
2045
  command: Extract<ProductCliCommand, { kind: "database-webhook-create" }>,
@@ -3479,6 +3510,49 @@ const scheduleSetRequest = async (
3479
3510
  }
3480
3511
  };
3481
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
+
3482
3556
  const psqlUnavailable = (): CliResult =>
3483
3557
  failure(
3484
3558
  "database psql",
@@ -3586,6 +3660,7 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
3586
3660
  if (command.kind === "database-watch-create") return "database watch create";
3587
3661
  if (command.kind === "database-watch-list") return "database watch list";
3588
3662
  if (command.kind === "database-watch-delete") return "database watch delete";
3663
+ if (command.kind === "database-table-create") return "database table create";
3589
3664
  if (command.kind === "database-webhook-create") return "database webhook create";
3590
3665
  if (command.kind === "database-webhook-list") return "database webhook list";
3591
3666
  if (command.kind === "database-webhook-get") return "database webhook get";
@@ -13,6 +13,10 @@ import {
13
13
  type DatabaseWatchRequest,
14
14
  type DatabaseWebhookEventPattern,
15
15
  } from "@ohmyhost/contracts/database-events";
16
+ import {
17
+ DATABASE_TABLE_MAX_PRIMARY_KEY_COLUMNS,
18
+ isDatabaseTableIdentifier,
19
+ } from "@ohmyhost/contracts/database-tables";
16
20
  import {
17
21
  databaseSizeForMemory,
18
22
  type DatabaseComputeSelection,
@@ -416,6 +420,17 @@ export type ProductCliCommand =
416
420
  readonly yes: true;
417
421
  readonly credentialStore: "native";
418
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
+ }
419
434
  | {
420
435
  readonly kind: "database-watch-list";
421
436
  readonly projectId: string;
@@ -1033,6 +1048,7 @@ export type RecognizedCommandLabel =
1033
1048
  | "database watch create"
1034
1049
  | "database watch list"
1035
1050
  | "database watch delete"
1051
+ | "database table create"
1036
1052
  | "database webhook create"
1037
1053
  | "database webhook list"
1038
1054
  | "database webhook get"
@@ -1209,6 +1225,8 @@ export const recognizedCommandLabel = (argv: readonly string[]): RecognizedComma
1209
1225
  if (values[1] === "list") return "database watch list";
1210
1226
  if (values[1] === "delete") return "database watch delete";
1211
1227
  }
1228
+ if (value === "database" && values[0] === "table" && values[1] === "create")
1229
+ return "database table create";
1212
1230
  if (value === "database" && values[0] === "webhook") {
1213
1231
  if (values[1] === "create") return "database webhook create";
1214
1232
  if (values[1] === "list") return "database webhook list";
@@ -2184,6 +2202,51 @@ export const parseProductCliCommand = (argv: readonly string[]): ProductCliComma
2184
2202
  }
2185
2203
  throw new InvalidCommandError();
2186
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
+ }
2187
2250
  if (first === "database" && values[0] === "webhook") {
2188
2251
  values.shift();
2189
2252
  const subcommand = values.shift();
@@ -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,
@@ -162,6 +169,7 @@ import {
162
169
  createProjectDatabaseWatch,
163
170
  listProjectDatabaseWatches,
164
171
  deleteProjectDatabaseWatch,
172
+ createProjectDatabaseTable,
165
173
  listProjectDatabaseWebhookDeliveries,
166
174
  replayProjectDatabaseWebhookDelivery,
167
175
  setProjectDatabaseSchedule,
@@ -215,6 +223,39 @@ export interface ProjectCreateInput {
215
223
  readonly idempotencyKey: string;
216
224
  }
217
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
+
218
259
  export interface ProductApi {
219
260
  readonly managedSource?: ManagedSourceApi;
220
261
  planProjectDataChange(input: {
@@ -376,6 +417,12 @@ export interface ProductApi {
376
417
  readonly idempotencyKey: string;
377
418
  readonly watchId: string;
378
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>;
379
426
  listProjectDatabaseWebhookDeliveries(input: {
380
427
  readonly projectId: string;
381
428
  readonly environment: "dev" | "prod";
@@ -593,9 +640,54 @@ export interface ProductApi {
593
640
  /** A webhook answer that names another environment, webhook or delivery than the request did. */
594
641
  const WEBHOOK_SCOPE_MISMATCH = "Webhook response scope mismatch";
595
642
  const WATCH_SCOPE_MISMATCH = "Watch response scope mismatch";
643
+ const TABLE_SCOPE_MISMATCH = "Table response scope mismatch";
596
644
  /** A schedule answer that names another environment or schedule than the request did. */
597
645
  const SCHEDULE_SCOPE_MISMATCH = "Schedule response scope mismatch";
598
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
+
599
691
  export class GeneratedSdkProductApi implements ProductApi {
600
692
  readonly #client: OhMyHostClient;
601
693
  public readonly managedSource: ManagedSourceApi;
@@ -973,6 +1065,30 @@ export class GeneratedSdkProductApi implements ProductApi {
973
1065
  return value;
974
1066
  }
975
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
+
976
1092
  /** The signing secret exists only in this answer; the CLI prints it once and stores nothing. */
977
1093
  public async createProjectDatabaseWebhook(
978
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",