@polardbx/pxbaas 0.1.0-beta.2 → 0.1.0-beta.3

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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Schema introspection shared by `db tables`, `db schema`, and `gen types`.
3
+ *
4
+ * Why this exists (fixes defect C1): those three commands used the Meta API
5
+ * (`MetaDriver` -> `/pg/*`) unconditionally. In multi-tenant mode `/pg/*` only
6
+ * accepts the platform/global service_role key, so a per-tenant key is rejected
7
+ * with 401 (`Authentication failed`) — even though the same credentials work for
8
+ * `db query` (which already routes through tenant-ddl), `data *` (REST), and
9
+ * `storage`. See `db/apply-schema.ts` ("per-tenant keys are rejected 401 on
10
+ * /pg/query").
11
+ *
12
+ * The fix keeps single-tenant behavior EXACTLY as before (Meta API) and adds a
13
+ * multi-tenant path that runs the same portable `information_schema` queries
14
+ * through `TenantDdlDriver` (which the tenant key CAN call). The introspection
15
+ * SQL is backend-agnostic, so it is factored into pure functions that take an
16
+ * injectable `SqlRunner` — trivial to unit-test without a network.
17
+ */
18
+ import { MetaDriver, TenantDdlDriver, } from '@polardbx/pxbaas-client';
19
+ /** Escape a single-quoted SQL string literal. */
20
+ function esc(value) {
21
+ return value.replace(/'/g, "''");
22
+ }
23
+ /**
24
+ * Schema predicate for information_schema filters. An explicit schema filters by
25
+ * name; otherwise scope to the connection's current database via DATABASE() —
26
+ * correct for multi-tenant, where the tenant-ddl connection is bound to the
27
+ * tenant's own database (tenant_<id>), not a Postgres-style `public`.
28
+ */
29
+ function schemaPredicate(schema) {
30
+ return schema && schema !== ''
31
+ ? `table_schema = '${esc(schema)}'`
32
+ : 'table_schema = DATABASE()';
33
+ }
34
+ function toBool(v) {
35
+ return v === 1 || v === true || v === '1';
36
+ }
37
+ /** List tables/views via information_schema (portable across both drivers). */
38
+ export async function listTablesViaSql(run, schema) {
39
+ const sql = `SELECT table_schema AS \`schema\`, table_name AS \`name\`, table_type AS \`type\`, ` +
40
+ `table_rows AS \`rowCount\`, table_comment AS \`comment\` ` +
41
+ `FROM information_schema.tables ` +
42
+ `WHERE ${schemaPredicate(schema)} AND table_type IN ('BASE TABLE','VIEW') ` +
43
+ `ORDER BY table_name`;
44
+ const rows = await run(sql);
45
+ return rows.map((t) => ({
46
+ schema: String(t.schema ?? ''),
47
+ name: String(t.name ?? ''),
48
+ type: String(t.type ?? '').toUpperCase() === 'VIEW' ? 'view' : 'table',
49
+ rowCount: t.rowCount != null ? Number(t.rowCount) : undefined,
50
+ comment: t.comment != null && t.comment !== '' ? String(t.comment) : undefined,
51
+ }));
52
+ }
53
+ /** List a table's columns via information_schema (mirrors MetaDriver SQL). */
54
+ export async function getTableColumnsViaSql(run, table, schema) {
55
+ const sql = `SELECT column_name AS \`name\`, data_type AS \`dataType\`, ` +
56
+ `CASE WHEN is_nullable='YES' THEN 1 ELSE 0 END AS \`isNullable\`, ` +
57
+ `column_default AS \`defaultValue\`, ` +
58
+ `CASE WHEN column_key='PRI' THEN 1 ELSE 0 END AS \`isPrimaryKey\`, ` +
59
+ `column_comment AS \`comment\` ` +
60
+ `FROM information_schema.columns ` +
61
+ `WHERE ${schemaPredicate(schema)} AND table_name='${esc(table)}' ` +
62
+ `ORDER BY ordinal_position`;
63
+ const rows = await run(sql);
64
+ return rows.map((row) => ({
65
+ name: String(row.name),
66
+ dataType: String(row.dataType),
67
+ isNullable: toBool(row.isNullable),
68
+ defaultValue: row.defaultValue == null ? null : String(row.defaultValue),
69
+ isPrimaryKey: toBool(row.isPrimaryKey),
70
+ comment: row.comment != null && row.comment !== '' ? String(row.comment) : undefined,
71
+ }));
72
+ }
73
+ /** List a table's indexes via information_schema (mirrors MetaDriver SQL). */
74
+ export async function getTableIndexesViaSql(run, table, schema) {
75
+ const sql = `SELECT index_name AS \`name\`, ` +
76
+ `GROUP_CONCAT(column_name ORDER BY seq_in_index) AS \`cols\`, ` +
77
+ `CASE WHEN MIN(non_unique)=0 THEN 1 ELSE 0 END AS \`isUnique\`, ` +
78
+ `CASE WHEN index_name='PRIMARY' THEN 1 ELSE 0 END AS \`isPrimary\` ` +
79
+ `FROM information_schema.statistics ` +
80
+ `WHERE ${schemaPredicate(schema)} AND table_name='${esc(table)}' ` +
81
+ `GROUP BY index_name`;
82
+ const rows = await run(sql);
83
+ return rows.map((row) => ({
84
+ name: String(row.name),
85
+ columns: row.cols != null && row.cols !== '' ? String(row.cols).split(',') : [],
86
+ isUnique: toBool(row.isUnique),
87
+ isPrimary: toBool(row.isPrimary),
88
+ type: 'BTREE',
89
+ }));
90
+ }
91
+ /**
92
+ * Build the mode-appropriate introspector.
93
+ * - multi-tenant: TenantDdlDriver + information_schema scoped to DATABASE().
94
+ * - single-tenant: the existing MetaDriver behavior, unchanged.
95
+ */
96
+ export function makeIntrospector(opts) {
97
+ if (opts.mode === 'multi-tenant') {
98
+ if (!opts.tenantId) {
99
+ throw new Error('Multi-tenant profile is missing tenantId; re-run "pxbaas login --tenant-id <id>" with per-tenant keys.');
100
+ }
101
+ const tenantDriver = new TenantDdlDriver({
102
+ endpoint: opts.endpoint,
103
+ serviceRoleKey: opts.serviceRoleKey,
104
+ });
105
+ const run = async (sql) => (await tenantDriver.query(sql));
106
+ let cachedSchema = null;
107
+ return {
108
+ async currentSchema() {
109
+ if (cachedSchema == null) {
110
+ const rows = await run('SELECT DATABASE() AS db');
111
+ cachedSchema = rows.length > 0 ? String(rows[0].db ?? '') : '';
112
+ }
113
+ return cachedSchema;
114
+ },
115
+ listTables: () => listTablesViaSql(run),
116
+ getColumns: (table) => getTableColumnsViaSql(run, table),
117
+ getIndexes: (table) => getTableIndexesViaSql(run, table),
118
+ };
119
+ }
120
+ const metaDriver = new MetaDriver({
121
+ endpoint: opts.endpoint,
122
+ anonKey: opts.anonKey,
123
+ serviceRoleKey: opts.serviceRoleKey,
124
+ tenantId: opts.tenantId,
125
+ });
126
+ const schema = opts.schema ?? 'public';
127
+ const useServiceRole = opts.useServiceRole ?? true;
128
+ return {
129
+ async currentSchema() {
130
+ return schema;
131
+ },
132
+ async listTables() {
133
+ const all = await metaDriver.listTables(useServiceRole);
134
+ return all.filter((t) => t.schema === schema);
135
+ },
136
+ getColumns: (table) => metaDriver.getTableColumns(table, schema, useServiceRole),
137
+ getIndexes: (table) => metaDriver.getTableIndexes(table, schema, useServiceRole),
138
+ };
139
+ }
140
+ //# sourceMappingURL=introspect.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"introspect.js","sourceRoot":"","sources":["../../src/db/introspect.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polardbx/pxbaas",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "PolarDB-X BaaS CLI application",
5
5
  "keywords": [
6
6
  "polardbx",
@@ -35,7 +35,7 @@
35
35
  "inquirer": "^9.2.0",
36
36
  "jsonwebtoken": "^9.0.0",
37
37
  "keytar": "^7.9.0",
38
- "@polardbx/pxbaas-client": "0.1.0-beta.2"
38
+ "@polardbx/pxbaas-client": "0.1.0-beta.3"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/inquirer": "^9.0.0",
@@ -98,7 +98,7 @@ Apps may declare `frontend.fallback: "worker"` in app.json: static-miss non-`/ap
98
98
  - **React SSR emits no event handlers**: `renderToString` drops every `on*` prop (lowercase `onclick` included). Wire interactivity with `data-*` attributes (passed through verbatim) plus one client-side delegated listener; verify by grepping the served HTML for the attribute, not by reading component code.
99
99
  - **MUST** fetch data client-side after hydration (relative `./api/*` or the instance REST/Auth URLs with the user's JWT) for the default SSR-shell shape. For server-side data fetching (L3 full SSR / SEO first paint), **MUST** use the platform-injected `SUPABASE_INTERNAL_URL` as the base — it is the ONLY origin reachable from inside the sandbox (the public `SUPABASE_URL` is egress-filtered and cluster service names do not resolve). The platform derives `SUPABASE_INTERNAL_URL` from the public host via DX-12 (edge-runtime `app-env.ts`, MR #30497903); the worker just reads it and **MUST NOT** re-derive from `SUPABASE_URL` or fetch `SUPABASE_URL` directly. Use `SUPABASE_ANON_KEY` (or the forwarded user JWT) as the credential; server-side RLS still applies on the internal base. These three public keys reach App candidate workers only on sandbox images carrying MR #30497903 — degrade gracefully (render the shell without server data) when `SUPABASE_INTERNAL_URL` is empty.
100
100
  - SSR responses are `private, no-cache` with the platform CSP (inline hydrate scripts allowed); do not rely on shared caching of rendered pages.
101
- - **Path shapes (interim contract while `APP_WORKER_PATH_REWRITE` is off)**: the `/api/*` branch delivers the STRIPPED logical path to the worker (`/apps/{m}/api/data` → worker sees `/data`), while static-miss fallthrough delivers the FULL logical path (`/apps/{m}/page` → worker sees `/page`). Workers MUST register both the stripped and `/api`-prefixed shapes for every JSON endpoint (the `ssr-hono` template does); hydrate scripts calling `./api/*` from an SSR page rely on the stripped shape being routed.
101
+ - **Path shapes (current default, `APP_WORKER_PATH_REWRITE` OFF)**: the proxy forwards EVERY worker request as `/<candidateSlug>/<logicalPath>` — the candidate slug prefix is **not** stripped at runtime. So `/apps/{m}/api/data` reaches the worker as `/<candidateSlug>/data`, and a static-miss fallthrough `/apps/{m}/page` reaches it as `/<candidateSlug>/page` (the `/api` segment is consumed by the proxy; the slug is prepended for edge-runtime function routing). The stripped logical path is delivered ONLY via the `X-Pxbaas-Worker-Path` header when `APP_WORKER_PATH_REWRITE` is ON, which is **disabled by default** (the runtime `new Request(url, req)` rewrite crashes with a streamRid TypeError on current sandbox binaries — D18). Workers MUST therefore normalize the leading `/<candidateSlug>` prefix and match on the logical path, e.g. `const p = new URL(req.url).pathname.replace(/^\/app_[^/]+/, "")` then dispatch on `p` (the `ssr-hono`, `ssr-shop`, `ssr-blog`, `csr-bff` templates all do this). A literal `app.get("/data")` will NOT match; do not rely on the declared `pathMode: "strip-app-api-prefix"` being honored at runtime yet. Hydrate scripts calling `./api/*` from an SSR page are routed the same way (browser `/apps/{m}/api/data` → worker `/<candidateSlug>/data`).
102
102
  - Start from the `ssr-hono` template (`pxbaas app new --template ssr-hono`); spec-lint enforces the rules above (`ssr-probe-declared`, `ssr-self-contained`, `ssr-marker-present`, `ssr-no-browser-globals`, `ssr-lib-not-allowed`).
103
103
 
104
104
  ## Lifecycle & cold start
@@ -83,3 +83,18 @@ pxbaas rest POST "/todos" --data '{"title":"x"}' --role anon --json
83
83
  ```
84
84
 
85
85
  > REST endpoint details, embedding/RPC constraints: fetch `${endpoint}/docs` — see api-reference.md.
86
+
87
+ ## Schema cache after DDL
88
+
89
+ The REST layer (`/rest/v1`) caches each table's column list. After a DDL change
90
+ (`ALTER TABLE ... ADD/DROP/MODIFY COLUMN`, or `CREATE`/`DROP TABLE`) the cache
91
+ re-introspects automatically: immediately where the platform wires DDL to a
92
+ cache reload, otherwise within one refresh interval (~30s). Until then:
93
+
94
+ - `select=*` (and `Prefer: return=representation` bodies) may still omit a
95
+ just-added column. This is a stale column list, not an error — the row is
96
+ returned, only the new field is missing.
97
+ - Workaround: enumerate columns explicitly (`?select=id,room_id,board`) instead
98
+ of `*`, or retry after a few seconds.
99
+
100
+ `pxbaas db add-column` / `db create-table` print a reminder about this.
@@ -10,10 +10,27 @@ app.use("*", async (context, next) => {
10
10
  });
11
11
 
12
12
  app.options("*", (context) => context.body(null, 204));
13
- app.get("/health", (context) => context.json({ ok: true, app: "{{SLUG}}" }));
14
- app.get("/hello", (context) =>
15
- context.json({ message: "Hello from {{APP_NAME}}" }),
16
- );
13
+
14
+ // Path contract (APP_WORKER_PATH_REWRITE off = current default): the proxy
15
+ // forwards /apps/{mount}/api/<x> to the worker as /<candidateSlug>/<x>, so a
16
+ // literal app.get("/health") never matches at runtime. Normalize the candidate
17
+ // slug prefix away and dispatch on the logical path (same convention as the
18
+ // ssr-shop / ssr-blog / csr-bff templates).
19
+ function norm(path: string): string {
20
+ return path.replace(/^\/app_[^/]+/, "") || path;
21
+ }
22
+
23
+ app.get("*", (context) => {
24
+ const p = norm(new URL(context.req.url).pathname);
25
+ if (p === "/health" || p === "/api/health") {
26
+ return context.json({ ok: true, app: "{{SLUG}}" });
27
+ }
28
+ if (p === "/hello" || p === "/api/hello") {
29
+ return context.json({ message: "Hello from {{APP_NAME}}" });
30
+ }
31
+ return context.json({ code: "NOT_FOUND", message: "Route not found", path: p }, 404);
32
+ });
33
+
17
34
  app.notFound((context) =>
18
35
  context.json({ code: "NOT_FOUND", message: "Route not found" }, 404),
19
36
  );
@@ -37,30 +37,45 @@ function esc(s: string): string {
37
37
  .split(String.fromCharCode(34)).join("&quot;");
38
38
  }
39
39
 
40
- // L3 shape demo: server-side fetch of DB content via the derived internal base
41
- // (anon key from env; RLS enforced server-side). Degrades gracefully so the
42
- // shell still renders if the internal entry is ever unreachable.
43
- app.get("/ssr-data", async (c) => {
44
- let rows: Array<Record<string, unknown>> = [];
45
- let note = "";
46
- try {
47
- const r = await fetch(internalBase() + "/rest/v1/?select=1", {
48
- headers: { apikey: Deno.env.get("SUPABASE_ANON_KEY") || "" },
49
- });
50
- note = `internal base reachable: http-${r.status}`;
51
- } catch (e) {
52
- note = `internal base unreachable: ${String(e).slice(0, 60)}`;
53
- }
54
- return c.html(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>L3 demo</title></head><body>
40
+ // Path contract (APP_WORKER_PATH_REWRITE off = current default): the proxy
41
+ // forwards /apps/{mount}/api/<x> to the worker as /<candidateSlug>/<x> — the
42
+ // candidate slug prefix is NOT stripped at runtime (the stripped path is only
43
+ // delivered via X-Pxbaas-Worker-Path when the rewrite kill-switch is ON, which
44
+ // is disabled by default due to the D18 streamRid regression). Normalize the
45
+ // slug prefix away and match on the logical path, so handlers resolve under ANY
46
+ // prefix shape (/{slug}/x, /api/x, or /x) and still work if the switch flips on.
47
+ // Same convention as the ssr-shop / ssr-blog / csr-bff templates.
48
+ function norm(path: string): string {
49
+ return path.replace(/^\/app_[^/]+/, "") || path;
50
+ }
51
+
52
+ app.get("*", async (c) => {
53
+ const p = norm(new URL(c.req.url).pathname);
54
+
55
+ // L3 shape demo: server-side fetch of DB content via the derived internal base
56
+ // (anon key from env; RLS enforced server-side). Degrades gracefully so the
57
+ // shell still renders if the internal entry is ever unreachable.
58
+ if (p === "/ssr-data") {
59
+ let rows: Array<Record<string, unknown>> = [];
60
+ let note = "";
61
+ try {
62
+ const r = await fetch(internalBase() + "/rest/v1/?select=1", {
63
+ headers: { apikey: Deno.env.get("SUPABASE_ANON_KEY") || "" },
64
+ });
65
+ note = `internal base reachable: http-${r.status}`;
66
+ } catch (e) {
67
+ note = `internal base unreachable: ${String(e).slice(0, 60)}`;
68
+ }
69
+ return c.html(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>L3 demo</title></head><body>
55
70
  <div id="__ssr" data-ssr="v1"><h1>L3 full-SSR demo</h1><p>${esc(note)}</p><pre>${esc(JSON.stringify(rows))}</pre></div>
56
71
  </body></html>`);
57
- });
72
+ }
58
73
 
59
- app.get("/page", (c) => {
60
- const theme = c.req.query("theme") === "dark" ? "dark" : "light";
61
- const bg = theme === "dark" ? "#111" : "#fafafa";
62
- const fg = theme === "dark" ? "#eee" : "#222";
63
- const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
74
+ if (p === "/page") {
75
+ const theme = c.req.query("theme") === "dark" ? "dark" : "light";
76
+ const bg = theme === "dark" ? "#111" : "#fafafa";
77
+ const fg = theme === "dark" ? "#eee" : "#222";
78
+ const html = `<!DOCTYPE html><html><head><meta charset="utf-8">
64
79
  <title>{{APP_NAME}} SSR</title>
65
80
  <style>body{font-family:sans-serif;max-width:720px;margin:40px auto;background:${bg};color:${fg}}</style>
66
81
  </head><body>
@@ -77,16 +92,20 @@ app.get("/page", (c) => {
77
92
  })();
78
93
  </script>
79
94
  </body></html>`;
80
- return c.html(html);
81
- });
95
+ return c.html(html);
96
+ }
97
+
98
+ // JSON API. /apps/{m}/api/data and /api/health normalize to /data and /health
99
+ // after the slug strip; the /api-prefixed shapes are kept as a defensive
100
+ // fallback for deployments that deliver the logical path un-stripped.
101
+ if (p === "/data" || p === "/api/data") {
102
+ return c.json({ ok: true, engine: "hono", now: new Date().toISOString() });
103
+ }
104
+ if (p === "/health" || p === "/api/health") {
105
+ return c.json({ ok: true });
106
+ }
82
107
 
83
- // Path contract (APP_WORKER_PATH_REWRITE off, current default): the /api/* branch
84
- // delivers the STRIPPED logical path (/data, /health) while static-miss
85
- // fallthrough delivers the FULL logical path (/page). Register both shapes so
86
- // the worker behaves identically when the rewrite kill-switch is flipped back on.
87
- app.get("/data", (c) => c.json({ ok: true, engine: "hono", now: new Date().toISOString() }));
88
- app.get("/api/data", (c) => c.json({ ok: true, engine: "hono", now: new Date().toISOString() }));
89
- app.get("/health", (c) => c.json({ ok: true }));
90
- app.get("/api/health", (c) => c.json({ ok: true }));
108
+ return c.json({ ok: false, error: "not_found", path: p }, 404);
109
+ });
91
110
 
92
111
  Deno.serve(app.fetch);