@hasna/shortlinks 0.1.24 → 0.2.1

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.
@@ -6,9 +6,6 @@ type PgAdapterLike = {
6
6
  run(sql: string, ...params: unknown[]): Promise<unknown>;
7
7
  close?: () => Promise<void>;
8
8
  };
9
- export interface PgConnectionOptions {
10
- ssl?: boolean;
11
- }
12
9
  /**
13
10
  * Adapt a vendored storage-kit `TypedQueryClient` (which speaks `$1` positional
14
11
  * params) to the `?`-placeholder `PgAdapterLike` the store queries are written
@@ -19,15 +16,20 @@ export declare function createKitPgAdapter(client: TypedQueryClient): PgAdapterL
19
16
  export declare class PgShortlinksStore {
20
17
  private readonly pg;
21
18
  constructor(pg: PgAdapterLike);
22
- static fromConnectionString(connectionString: string, options?: PgConnectionOptions): Promise<PgShortlinksStore>;
23
- /** Build a store over a vendored storage-kit query client (the serve path). */
19
+ /**
20
+ * Build a store over a vendored storage-kit query client. This is the ONLY
21
+ * constructor: the serve entrypoint opens its pool via `createCloudPoolFromEnv`
22
+ * (server-side) and hands the client here. There is deliberately no
23
+ * DSN-from-env / connection-string path so this store can never be misused to
24
+ * open the raw RDS from a client.
25
+ */
24
26
  static fromQueryClient(client: TypedQueryClient): PgShortlinksStore;
25
- static fromEnv(env?: NodeJS.ProcessEnv): Promise<PgShortlinksStore>;
26
27
  close(): Promise<void>;
27
28
  addDomain(input: AddDomainInput): Promise<Domain>;
28
29
  listDomains(): Promise<Domain[]>;
29
30
  getDomain(hostnameOrId: string): Promise<Domain | null>;
30
31
  getDefaultDomain(): Promise<Domain | null>;
32
+ deleteDomain(hostnameOrId: string): Promise<Domain>;
31
33
  createLink(input: CreateLinkInput): Promise<Link>;
32
34
  listLinks(options?: {
33
35
  domain?: string;
@@ -48,9 +50,4 @@ export declare class PgShortlinksStore {
48
50
  private hashIp;
49
51
  private generateAvailableSlug;
50
52
  }
51
- export declare function applyPostgresMigrations(connectionString: string, migrations: string[], options?: PgConnectionOptions): Promise<{
52
- service: "shortlinks";
53
- applied: number[];
54
- skipped: number[];
55
- }>;
56
53
  export {};
package/dist/pg-store.js CHANGED
@@ -32,170 +32,6 @@ var __toESM = (mod, isNodeMode, target) => {
32
32
  var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
33
33
  var __require = import.meta.require;
34
34
 
35
- // src/runtime.ts
36
- var SHORTLINKS_RUNTIME_ENV = {
37
- store: "HASNA_SHORTLINKS_STORE",
38
- databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
39
- databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
40
- };
41
- var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
42
- store: "SHORTLINKS_STORE",
43
- databaseUrl: "SHORTLINKS_DATABASE_URL",
44
- databaseSsl: "SHORTLINKS_DATABASE_SSL"
45
- };
46
- var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
47
- var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
48
- var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
49
- function getCanonicalShortlinksPostgresConfig() {
50
- return {
51
- cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
52
- database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
53
- runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
54
- primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
55
- fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
56
- };
57
- }
58
- function parseShortlinksStoreMode(value) {
59
- const normalized = clean(value)?.toLowerCase();
60
- if (!normalized)
61
- return "local";
62
- if (normalized === "local" || normalized === "postgres")
63
- return normalized;
64
- if (normalized === "pg")
65
- return "postgres";
66
- throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
67
- }
68
- function getShortlinksStoreMode(env = process.env) {
69
- return parseShortlinksStoreMode(readRuntimeEnv(env, "store").value);
70
- }
71
- function getShortlinksDatabaseUrl(env = process.env) {
72
- return readRuntimeEnv(env, "databaseUrl").value;
73
- }
74
- function getShortlinksDatabaseSsl(env = process.env) {
75
- return parseBoolean(readRuntimeEnv(env, "databaseSsl").value, true);
76
- }
77
- function getShortlinksRuntimeEnvName(env, key) {
78
- const primary = SHORTLINKS_RUNTIME_ENV[key];
79
- if (clean(env[primary]))
80
- return primary;
81
- return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
82
- }
83
- function loadShortlinksRuntimeConfig(env = process.env) {
84
- const mode = getShortlinksStoreMode(env);
85
- const databaseUrl = getShortlinksDatabaseUrl(env);
86
- return {
87
- service: "shortlinks",
88
- mode,
89
- ...databaseUrl ? {
90
- database: {
91
- provider: "postgres",
92
- url: databaseUrl,
93
- ssl: getShortlinksDatabaseSsl(env)
94
- }
95
- } : {}
96
- };
97
- }
98
- function assertShortlinksPostgresConfig(config) {
99
- if (config.mode !== "postgres")
100
- return;
101
- if (!config.database?.url) {
102
- throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
103
- }
104
- }
105
- function getShortlinksRuntimeStatus(env = process.env) {
106
- const issues = [];
107
- const warnings = [];
108
- let config;
109
- try {
110
- config = loadShortlinksRuntimeConfig(env);
111
- } catch (error) {
112
- issues.push(error instanceof Error ? error.message : String(error));
113
- config = { service: "shortlinks", mode: "local" };
114
- }
115
- try {
116
- assertShortlinksPostgresConfig(config);
117
- } catch (error) {
118
- issues.push(error instanceof Error ? error.message : String(error));
119
- }
120
- if (config.mode === "local" && config.database?.url) {
121
- warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
122
- }
123
- return {
124
- ok: issues.length === 0,
125
- service: "shortlinks",
126
- mode: config.mode,
127
- local_default: config.mode === "local",
128
- postgres_enabled: config.mode === "postgres",
129
- database: {
130
- configured: Boolean(config.database?.url),
131
- provider: config.database?.provider ?? null,
132
- redacted_url: redactDatabaseUrl(config.database?.url),
133
- ssl: config.database?.ssl ?? null
134
- },
135
- env: runtimeEnvStatus(env),
136
- canonical: getCanonicalShortlinksPostgresConfig(),
137
- issues,
138
- warnings,
139
- no_network: true
140
- };
141
- }
142
- function redactDatabaseUrl(value) {
143
- if (!value)
144
- return null;
145
- try {
146
- const url = new URL(value);
147
- if (url.username)
148
- url.username = "***";
149
- if (url.password)
150
- url.password = "***";
151
- for (const key of Array.from(url.searchParams.keys())) {
152
- if (isSensitiveQueryKey(key))
153
- url.searchParams.set(key, "***");
154
- }
155
- return url.toString();
156
- } catch {
157
- return "(redacted)";
158
- }
159
- }
160
- function runtimeEnvStatus(env) {
161
- return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
162
- const activeName = getShortlinksRuntimeEnvName(env, key);
163
- return [
164
- key,
165
- {
166
- name,
167
- active_name: activeName,
168
- configured: Boolean(clean(env[activeName]))
169
- }
170
- ];
171
- }));
172
- }
173
- function readRuntimeEnv(env, key) {
174
- const primary = SHORTLINKS_RUNTIME_ENV[key];
175
- const primaryValue = clean(env[primary]);
176
- if (primaryValue)
177
- return { name: primary, value: primaryValue };
178
- const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
179
- return { name: fallback, value: clean(env[fallback]) };
180
- }
181
- function parseBoolean(value, fallback) {
182
- const normalized = clean(value)?.toLowerCase();
183
- if (!normalized)
184
- return fallback;
185
- if (["1", "true", "yes", "on"].includes(normalized))
186
- return true;
187
- if (["0", "false", "no", "off"].includes(normalized))
188
- return false;
189
- throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
190
- }
191
- function isSensitiveQueryKey(key) {
192
- return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
193
- }
194
- function clean(value) {
195
- const trimmed = value?.trim();
196
- return trimmed ? trimmed : undefined;
197
- }
198
-
199
35
  // src/pg-store.ts
200
36
  import { createHash } from "crypto";
201
37
 
@@ -497,47 +333,10 @@ function parseJsonObject(value) {
497
333
  return {};
498
334
  }
499
335
  }
500
- async function loadPgPool() {
501
- const importer = new Function("specifier", "return import(specifier)");
502
- const module = await importer("pg");
503
- return module.Pool;
504
- }
505
336
  function toPostgresSql(sql) {
506
337
  let index = 0;
507
338
  return sql.replace(/\?/g, () => `$${++index}`);
508
339
  }
509
- function createPgPoolConfig(connectionString, options = {}) {
510
- const ssl = options.ssl ?? true;
511
- return {
512
- connectionString,
513
- ...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
514
- };
515
- }
516
-
517
- class PgPoolAdapter {
518
- pool;
519
- constructor(pool) {
520
- this.pool = pool;
521
- }
522
- static async create(connectionString, options = {}) {
523
- const Pool = await loadPgPool();
524
- return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
525
- }
526
- async get(sql, ...params) {
527
- const result = await this.pool.query(toPostgresSql(sql), params);
528
- return result.rows[0] ?? null;
529
- }
530
- async all(sql, ...params) {
531
- const result = await this.pool.query(toPostgresSql(sql), params);
532
- return result.rows;
533
- }
534
- async run(sql, ...params) {
535
- return this.pool.query(toPostgresSql(sql), params);
536
- }
537
- async close() {
538
- await this.pool.end();
539
- }
540
- }
541
340
  function toIsoString(value) {
542
341
  if (value instanceof Date)
543
342
  return value.toISOString();
@@ -619,19 +418,9 @@ class PgShortlinksStore {
619
418
  constructor(pg) {
620
419
  this.pg = pg;
621
420
  }
622
- static async fromConnectionString(connectionString, options = {}) {
623
- return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
624
- }
625
421
  static fromQueryClient(client) {
626
422
  return new PgShortlinksStore(createKitPgAdapter(client));
627
423
  }
628
- static async fromEnv(env = process.env) {
629
- const connectionString = getShortlinksDatabaseUrl(env);
630
- if (!connectionString) {
631
- throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
632
- }
633
- return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env) });
634
- }
635
424
  async close() {
636
425
  await this.pg.close?.();
637
426
  }
@@ -685,6 +474,13 @@ class PgShortlinksStore {
685
474
  `);
686
475
  return row ? domainFromRow(row) : null;
687
476
  }
477
+ async deleteDomain(hostnameOrId) {
478
+ const domain = await this.getDomain(hostnameOrId);
479
+ if (!domain)
480
+ throw new Error("Domain not found.");
481
+ await this.pg.run("DELETE FROM domains WHERE id = ?", domain.id);
482
+ return domain;
483
+ }
688
484
  async createLink(input) {
689
485
  const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
690
486
  if (!domain) {
@@ -860,54 +656,7 @@ class PgShortlinksStore {
860
656
  throw new Error("Could not generate an unused slug after 32 attempts.");
861
657
  }
862
658
  }
863
- async function applyPostgresMigrations(connectionString, migrations, options = {}) {
864
- const Pool = await loadPgPool();
865
- const pool = new Pool(createPgPoolConfig(connectionString, options));
866
- const client = await pool.connect();
867
- const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
868
- const get = async (sql, ...params) => {
869
- const result = await run(sql, ...params);
870
- return result.rows[0] ?? null;
871
- };
872
- const applied = [];
873
- const skipped = [];
874
- try {
875
- await run("BEGIN");
876
- await run(`
877
- SELECT pg_advisory_xact_lock(hashtext(?))
878
- `, "shortlinks:migrations");
879
- await run(`
880
- CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
881
- id INTEGER PRIMARY KEY,
882
- service TEXT NOT NULL DEFAULT 'shortlinks',
883
- applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
884
- )
885
- `);
886
- for (let i = 0;i < migrations.length; i += 1) {
887
- const id = i + 1;
888
- const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
889
- if (existing) {
890
- skipped.push(id);
891
- continue;
892
- }
893
- await run(migrations[i]);
894
- await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
895
- applied.push(id);
896
- }
897
- await run("COMMIT");
898
- return { service: "shortlinks", applied, skipped };
899
- } catch (error) {
900
- try {
901
- await run("ROLLBACK");
902
- } catch {}
903
- throw error;
904
- } finally {
905
- client.release();
906
- await pool.end();
907
- }
908
- }
909
659
  export {
910
660
  createKitPgAdapter,
911
- applyPostgresMigrations,
912
661
  PgShortlinksStore
913
662
  };
@@ -87,6 +87,10 @@ export interface DeleteResponse {
87
87
  "deleted": boolean;
88
88
  "slug"?: string;
89
89
  }
90
+ export interface DomainDeleteResponse {
91
+ "deleted": boolean;
92
+ "hostname"?: string;
93
+ }
90
94
  export interface HealthStatus {
91
95
  "status": string;
92
96
  "version": string;
@@ -139,6 +143,8 @@ export declare class ShortlinksApiClient {
139
143
  listDomains(init?: RequestInit): Promise<DomainList>;
140
144
  /** Add or update a domain. */
141
145
  addDomain(body: AddDomainRequest, init?: RequestInit): Promise<Domain>;
146
+ /** Delete a domain and all of its links and clicks. */
147
+ deleteDomain(hostname: string, init?: RequestInit): Promise<DomainDeleteResponse>;
142
148
  /** List shortlinks. */
143
149
  listLinks(query?: {
144
150
  "domain"?: string;
package/dist/sdk/index.js CHANGED
@@ -115,6 +115,13 @@ class ShortlinksApiClient {
115
115
  init
116
116
  });
117
117
  }
118
+ async deleteDomain(hostname, init) {
119
+ return this.request("DELETE", `/v1/domains/${encodeURIComponent(String(hostname))}`, {
120
+ body: undefined,
121
+ query: undefined,
122
+ init
123
+ });
124
+ }
118
125
  async listLinks(query, init) {
119
126
  return this.request("GET", `/v1/links`, {
120
127
  body: undefined,
@@ -311,6 +318,11 @@ function buildOpenApiDocument(version) {
311
318
  properties: { deleted: { type: "boolean" }, slug: { type: "string" } },
312
319
  required: ["deleted"]
313
320
  },
321
+ DomainDeleteResponse: {
322
+ type: "object",
323
+ properties: { deleted: { type: "boolean" }, hostname: { type: "string" } },
324
+ required: ["deleted"]
325
+ },
314
326
  HealthStatus: probe({ db_latency_ms: { type: "integer" } }),
315
327
  ReadyStatus: probe({ pending_migrations: { type: "array", items: { type: "string" } } }),
316
328
  VersionInfo: probe({ name: { type: "string" } }),
@@ -381,6 +393,19 @@ function buildOpenApiDocument(version) {
381
393
  }
382
394
  }
383
395
  },
396
+ "/v1/domains/{hostname}": {
397
+ delete: {
398
+ operationId: "deleteDomain",
399
+ summary: "Delete a domain and all of its links and clicks.",
400
+ security: [{ apiKey: [] }],
401
+ parameters: [
402
+ { name: "hostname", in: "path", required: true, schema: { type: "string" } }
403
+ ],
404
+ responses: {
405
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/DomainDeleteResponse" } } } }
406
+ }
407
+ }
408
+ },
384
409
  "/v1/links": {
385
410
  get: {
386
411
  operationId: "listLinks",