@malloy-publisher/server 0.0.207 → 0.0.209
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/app/api-doc.yaml +84 -66
- package/dist/app/assets/{EnvironmentPage-BScgHmkw.js → EnvironmentPage-BRMCY9d8.js} +1 -1
- package/dist/app/assets/HomePage-DQzgkiMI.js +1 -0
- package/dist/app/assets/{MainPage-DWfF4jXW.js → MainPage-sZdUjUcu.js} +2 -2
- package/dist/app/assets/MaterializationsPage-C_jQQUCJ.js +1 -0
- package/dist/app/assets/{ModelPage-BiNOgK_e.js → ModelPage-Bh62OIEq.js} +1 -1
- package/dist/app/assets/{PackagePage-DAN5V7gu.js → PackagePage-DJW4xjLp.js} +1 -1
- package/dist/app/assets/{RouteError-CEnIzuKN.js → RouteError-Vi5yGs_F.js} +1 -1
- package/dist/app/assets/{WorkbookPage-gA1ceqHP.js → WorkbookPage-BvJMi21d.js} +1 -1
- package/dist/app/assets/{core-AOmIKwkc.es-Dclu1Fga.js → core-BbW0t3RQ.es-L-mZcOk3.js} +1 -1
- package/dist/app/assets/index-CCcHdeew.js +1760 -0
- package/dist/app/assets/index-CNFX-CGL.js +40 -0
- package/dist/app/assets/{index-DtlPzNxc.js → index-DuqTjxM_.js} +114 -114
- package/dist/app/assets/index.umd-GgEb4WfT.js +2467 -0
- package/dist/app/index.html +1 -1
- package/dist/server.mjs +26182 -16839
- package/package.json +14 -11
- package/src/controller/materialization.controller.spec.ts +125 -0
- package/src/controller/materialization.controller.ts +23 -27
- package/src/health.ts +6 -0
- package/src/materialization_metrics.ts +117 -34
- package/src/mcp/agent_server.protocol.spec.ts +78 -0
- package/src/mcp/agent_server.spec.ts +18 -0
- package/src/mcp/agent_server.ts +144 -0
- package/src/mcp/server.ts +3 -0
- package/src/mcp/skills/build_skills_bundle.spec.ts +51 -0
- package/src/mcp/skills/build_skills_bundle.ts +66 -0
- package/src/mcp/skills/skills_bundle.json +1 -0
- package/src/mcp/skills/skills_bundle.spec.ts +33 -0
- package/src/mcp/tools/docs_search/build_docs_index.ts +132 -0
- package/src/mcp/tools/docs_search/malloy_docs_index.json +1 -0
- package/src/mcp/tools/docs_search_tool.spec.ts +32 -0
- package/src/mcp/tools/docs_search_tool.ts +138 -0
- package/src/mcp/tools/get_context_eval.ts +126 -0
- package/src/mcp/tools/get_context_tool.spec.ts +44 -0
- package/src/mcp/tools/get_context_tool.ts +341 -0
- package/src/server-old.ts +2 -11
- package/src/server.ts +16 -10
- package/src/service/build_plan.spec.ts +116 -0
- package/src/service/build_plan.ts +238 -0
- package/src/service/connection.ts +4 -0
- package/src/service/connection_config.spec.ts +182 -1
- package/src/service/connection_config.ts +70 -0
- package/src/service/db_utils.spec.ts +159 -1
- package/src/service/db_utils.ts +131 -0
- package/src/service/materialization_service.spec.ts +388 -184
- package/src/service/materialization_service.ts +156 -442
- package/src/service/materialization_test_fixtures.ts +119 -0
- package/src/service/package.ts +41 -1
- package/src/storage/DatabaseInterface.ts +5 -13
- package/src/storage/duckdb/MaterializationRepository.ts +5 -14
- package/src/storage/duckdb/schema.ts +4 -5
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +72 -211
- package/tests/integration/watch-mode/watch_mode.integration.spec.ts +6 -0
- package/dist/app/assets/HomePage-CGedji_w.js +0 -1
- package/dist/app/assets/MaterializationsPage-B9PDlk8c.js +0 -1
- package/dist/app/assets/index-DGGe8UpP.js +0 -40
- package/dist/app/assets/index-uu6UpHd2.js +0 -1812
- package/dist/app/assets/index.umd-DDq93YX4.js +0 -2469
|
@@ -336,6 +336,60 @@ function validateConnectionShape(connection: ApiConnection): void {
|
|
|
336
336
|
}
|
|
337
337
|
break;
|
|
338
338
|
}
|
|
339
|
+
case "publisher": {
|
|
340
|
+
// SSRF gate (default-deny / fail-closed). A `publisher` connection
|
|
341
|
+
// makes THIS server issue outbound HTTP to a tenant-controlled
|
|
342
|
+
// `connectionUri` (both the query path in db-publisher's
|
|
343
|
+
// PublisherConnection and the introspection path in db_utils). That is
|
|
344
|
+
// the intended behavior for local `--watch-env` authoring, but in a
|
|
345
|
+
// hosted multi-tenant deployment (e.g. Credible running this server) it
|
|
346
|
+
// is an SSRF surface. Require an explicit opt-in so the type is refused
|
|
347
|
+
// unless the operator deliberately enabled it. This is the single
|
|
348
|
+
// choke point — every connection passes validateConnectionShape before
|
|
349
|
+
// it can be assembled, queried, or introspected — so denying here shuts
|
|
350
|
+
// off all three at once.
|
|
351
|
+
if (process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS !== "true") {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Publisher proxy connection '${connection.name}' is disabled in this deployment. ` +
|
|
354
|
+
`'publisher' connections make the server issue outbound requests to a configured connectionUri, ` +
|
|
355
|
+
`which is only appropriate for local --watch-env authoring. ` +
|
|
356
|
+
`Fix: set the environment variable PUBLISHER_ALLOW_PROXY_CONNECTIONS=true to enable them.`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
const publisher = connection.publisherConnection;
|
|
360
|
+
if (!publisher?.connectionUri) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`Invalid publisher connection '${connection.name}': missing connectionUri. ` +
|
|
363
|
+
`Fix: { "name": "${connection.name}", "type": "publisher", ` +
|
|
364
|
+
`"publisherConnection": { "connectionUri": "https://…/connections/${connection.name}", "accessToken": "<jwt>" } }`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
// Reject a malformed connectionUri here, at config-load, rather than
|
|
368
|
+
// letting it fail deep in the request path — where the thrown error can
|
|
369
|
+
// echo the raw value back, leaking any credentials embedded in it
|
|
370
|
+
// (`redactUrlCredentials` returns the URI unchanged when it can't parse
|
|
371
|
+
// it). Never include the raw connectionUri in these messages; the
|
|
372
|
+
// scheme is safe to name.
|
|
373
|
+
let parsedUri: URL;
|
|
374
|
+
try {
|
|
375
|
+
parsedUri = new URL(publisher.connectionUri);
|
|
376
|
+
} catch {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Invalid publisher connection '${connection.name}': connectionUri is not a valid URL. ` +
|
|
379
|
+
`Fix: set connectionUri to an absolute https URL like "https://…/connections/${connection.name}".`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (
|
|
383
|
+
parsedUri.protocol !== "http:" &&
|
|
384
|
+
parsedUri.protocol !== "https:"
|
|
385
|
+
) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`Invalid publisher connection '${connection.name}': connectionUri must use http or https (got '${parsedUri.protocol}'). ` +
|
|
388
|
+
`Fix: set connectionUri to an absolute https URL like "https://…/connections/${connection.name}".`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
339
393
|
}
|
|
340
394
|
}
|
|
341
395
|
|
|
@@ -546,6 +600,22 @@ export function assembleEnvironmentConnections(
|
|
|
546
600
|
break;
|
|
547
601
|
}
|
|
548
602
|
|
|
603
|
+
case "publisher": {
|
|
604
|
+
// connectionUri presence is validated by validateConnectionShape
|
|
605
|
+
// above. The proxied dataplane owns auth and read-only enforcement;
|
|
606
|
+
// PublisherConnection itself does not reject writes. The real
|
|
607
|
+
// dialect is the remote connection's and is resolved at runtime by
|
|
608
|
+
// the live connection, so getStaticConnectionAttributes returns
|
|
609
|
+
// undefined for publisher (falls through to its default).
|
|
610
|
+
const publisher = connection.publisherConnection!;
|
|
611
|
+
pojo.connections[connection.name] = {
|
|
612
|
+
is: "publisher",
|
|
613
|
+
connectionUri: publisher.connectionUri,
|
|
614
|
+
accessToken: publisher.accessToken,
|
|
615
|
+
};
|
|
616
|
+
break;
|
|
617
|
+
}
|
|
618
|
+
|
|
549
619
|
default: {
|
|
550
620
|
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
551
621
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it, mock } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, it, mock } from "bun:test";
|
|
2
2
|
|
|
3
3
|
// Stub the missing optional dependency so db_utils.ts can be imported
|
|
4
4
|
mock.module("@azure/identity", () => ({
|
|
@@ -873,3 +873,161 @@ describe("extractErrorDataFromError", () => {
|
|
|
873
873
|
expect(result.task).toEqual({ id: 1 });
|
|
874
874
|
});
|
|
875
875
|
});
|
|
876
|
+
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
// publisher proxy introspection
|
|
879
|
+
//
|
|
880
|
+
// The remote dataplane is the oracle for introspection correctness and is not
|
|
881
|
+
// simulated here. These tests cover only this server's own logic: the request
|
|
882
|
+
// URL it builds, the auth header, the bare-name table filter, and how it
|
|
883
|
+
// surfaces a non-ok / unreachable dataplane (credentials redacted from errors).
|
|
884
|
+
// ---------------------------------------------------------------------------
|
|
885
|
+
describe("publisher proxy introspection", () => {
|
|
886
|
+
const realFetch = globalThis.fetch;
|
|
887
|
+
let lastRequest:
|
|
888
|
+
| { url: string; headers: Record<string, string> }
|
|
889
|
+
| undefined;
|
|
890
|
+
|
|
891
|
+
function stubFetch(
|
|
892
|
+
payload: unknown,
|
|
893
|
+
init: { ok?: boolean; status?: number; body?: string } = {},
|
|
894
|
+
) {
|
|
895
|
+
lastRequest = undefined;
|
|
896
|
+
const ok = init.ok ?? true;
|
|
897
|
+
globalThis.fetch = (async (url: unknown, opts?: RequestInit) => {
|
|
898
|
+
lastRequest = {
|
|
899
|
+
url: String(url),
|
|
900
|
+
headers: (opts?.headers as Record<string, string>) ?? {},
|
|
901
|
+
};
|
|
902
|
+
return {
|
|
903
|
+
ok,
|
|
904
|
+
status: init.status ?? (ok ? 200 : 500),
|
|
905
|
+
json: async () => payload,
|
|
906
|
+
text: async () => init.body ?? "",
|
|
907
|
+
} as Response;
|
|
908
|
+
}) as typeof fetch;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
afterEach(() => {
|
|
912
|
+
globalThis.fetch = realFetch;
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
const conn: ApiConnection = {
|
|
916
|
+
name: "analytics",
|
|
917
|
+
type: "publisher",
|
|
918
|
+
publisherConnection: {
|
|
919
|
+
connectionUri:
|
|
920
|
+
"https://org.data.example.com/api/v0/environments/proj/connections/analytics",
|
|
921
|
+
accessToken: "jwt-token",
|
|
922
|
+
},
|
|
923
|
+
};
|
|
924
|
+
// No malloyConnection is used for the publisher path.
|
|
925
|
+
const noConn = undefined as unknown as Connection;
|
|
926
|
+
|
|
927
|
+
it("forwards GET /schemas to the connectionUri with a bearer token", async () => {
|
|
928
|
+
const schemas = [{ name: "main", isHidden: false, isDefault: true }];
|
|
929
|
+
stubFetch(schemas);
|
|
930
|
+
const result = await getSchemasForConnection(conn, noConn);
|
|
931
|
+
|
|
932
|
+
expect(lastRequest?.url).toBe(
|
|
933
|
+
"https://org.data.example.com/api/v0/environments/proj/connections/analytics/schemas",
|
|
934
|
+
);
|
|
935
|
+
expect(lastRequest?.headers["Authorization"]).toBe("Bearer jwt-token");
|
|
936
|
+
expect(result).toEqual(schemas);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
it("omits the Authorization header when no accessToken is configured", async () => {
|
|
940
|
+
const tokenless: ApiConnection = {
|
|
941
|
+
name: "analytics",
|
|
942
|
+
type: "publisher",
|
|
943
|
+
publisherConnection: {
|
|
944
|
+
connectionUri:
|
|
945
|
+
"https://org.data.example.com/api/v0/environments/proj/connections/analytics",
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
stubFetch([]);
|
|
949
|
+
await getSchemasForConnection(tokenless, noConn);
|
|
950
|
+
expect(lastRequest?.headers["Authorization"]).toBeUndefined();
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it("forwards GET /schemas/<schema>/tables and returns all tables when unfiltered", async () => {
|
|
954
|
+
const tables = [
|
|
955
|
+
{ resource: "main.orders" },
|
|
956
|
+
{ resource: "main.customers" },
|
|
957
|
+
];
|
|
958
|
+
stubFetch(tables);
|
|
959
|
+
const result = await listTablesForSchema(conn, "main", noConn);
|
|
960
|
+
|
|
961
|
+
expect(lastRequest?.url).toBe(
|
|
962
|
+
"https://org.data.example.com/api/v0/environments/proj/connections/analytics/schemas/main/tables",
|
|
963
|
+
);
|
|
964
|
+
expect(result).toEqual(tables);
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
it("URL-encodes the schema name in the tables path", async () => {
|
|
968
|
+
stubFetch([]);
|
|
969
|
+
await listTablesForSchema(conn, "weird schema/name", noConn);
|
|
970
|
+
expect(lastRequest?.url).toContain(
|
|
971
|
+
"/schemas/weird%20schema%2Fname/tables",
|
|
972
|
+
);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it("filters by bare table name across schema- and catalog-qualified resources", async () => {
|
|
976
|
+
const tables = [
|
|
977
|
+
{ resource: "main.orders" }, // "<schema>.<table>"
|
|
978
|
+
{ resource: "cat.main.customers" }, // "<catalog>.<schema>.<table>"
|
|
979
|
+
{ resource: "main.events" },
|
|
980
|
+
];
|
|
981
|
+
stubFetch(tables);
|
|
982
|
+
const result = await listTablesForSchema(conn, "main", noConn, [
|
|
983
|
+
"orders",
|
|
984
|
+
"customers",
|
|
985
|
+
]);
|
|
986
|
+
expect(result.map((t) => t.resource)).toEqual([
|
|
987
|
+
"main.orders",
|
|
988
|
+
"cat.main.customers",
|
|
989
|
+
]);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("returns an empty list when the filter matches nothing (not all rows)", async () => {
|
|
993
|
+
stubFetch([{ resource: "main.orders" }]);
|
|
994
|
+
const result = await listTablesForSchema(conn, "main", noConn, [
|
|
995
|
+
"nonexistent",
|
|
996
|
+
]);
|
|
997
|
+
expect(result).toEqual([]);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("throws an actionable error on a non-ok dataplane response", async () => {
|
|
1001
|
+
stubFetch(null, { ok: false, status: 403, body: "forbidden" });
|
|
1002
|
+
await expect(getSchemasForConnection(conn, noConn)).rejects.toThrow(
|
|
1003
|
+
"failed (403): forbidden",
|
|
1004
|
+
);
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it("redacts URL-embedded credentials from error messages", async () => {
|
|
1008
|
+
const withCreds: ApiConnection = {
|
|
1009
|
+
name: "analytics",
|
|
1010
|
+
type: "publisher",
|
|
1011
|
+
publisherConnection: {
|
|
1012
|
+
connectionUri:
|
|
1013
|
+
"https://user:secret@org.data.example.com/api/v0/environments/proj/connections/analytics",
|
|
1014
|
+
},
|
|
1015
|
+
};
|
|
1016
|
+
stubFetch(null, { ok: false, status: 500, body: "boom" });
|
|
1017
|
+
await expect(getSchemasForConnection(withCreds, noConn)).rejects.toThrow(
|
|
1018
|
+
/Publisher dataplane request to https:\/\/org\.data\.example\.com.*failed \(500\)/,
|
|
1019
|
+
);
|
|
1020
|
+
await expect(
|
|
1021
|
+
getSchemasForConnection(withCreds, noConn),
|
|
1022
|
+
).rejects.not.toThrow(/secret/);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it("surfaces a network-level failure with the connection name and no token", async () => {
|
|
1026
|
+
globalThis.fetch = (async () => {
|
|
1027
|
+
throw new Error("ECONNREFUSED");
|
|
1028
|
+
}) as unknown as typeof fetch;
|
|
1029
|
+
await expect(getSchemasForConnection(conn, noConn)).rejects.toThrow(
|
|
1030
|
+
'failed for connection "analytics": ECONNREFUSED',
|
|
1031
|
+
);
|
|
1032
|
+
});
|
|
1033
|
+
});
|
package/src/service/db_utils.ts
CHANGED
|
@@ -594,6 +594,133 @@ async function getSchemasForDuckLake(
|
|
|
594
594
|
}
|
|
595
595
|
}
|
|
596
596
|
|
|
597
|
+
/** Abort introspection requests that hang instead of holding the slot open. */
|
|
598
|
+
const PUBLISHER_INTROSPECTION_TIMEOUT_MS = 60_000;
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Strip any userinfo (`user:pass@`) from a URL before it reaches a log or error
|
|
602
|
+
* message. `connectionUri` comes from a config file and may legitimately carry
|
|
603
|
+
* embedded credentials; the bearer token always travels in the header, never
|
|
604
|
+
* the URL, but this keeps any URL-embedded secret out of thrown messages too.
|
|
605
|
+
*/
|
|
606
|
+
function redactUrlCredentials(url: string): string {
|
|
607
|
+
try {
|
|
608
|
+
const parsed = new URL(url);
|
|
609
|
+
parsed.username = "";
|
|
610
|
+
parsed.password = "";
|
|
611
|
+
return parsed.toString();
|
|
612
|
+
} catch {
|
|
613
|
+
// Non-absolute / unparseable: nothing to redact, return as-is.
|
|
614
|
+
return url;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Publisher proxy connections introspect against the remote dataplane, which
|
|
620
|
+
* exposes the same connection API as this server. Schema/table discovery is a
|
|
621
|
+
* GET passthrough — the remote owns the dialect-specific introspection and
|
|
622
|
+
* returns the same Schema/Table shapes, so we forward the response verbatim
|
|
623
|
+
* (no Malloy connection involved).
|
|
624
|
+
*
|
|
625
|
+
* URL contract: the request is the configured `connectionUri` with the
|
|
626
|
+
* introspection `pathSuffix` appended verbatim. `connectionUri` must point at
|
|
627
|
+
* the connection resource itself (`.../connections/<name>`), so this path and
|
|
628
|
+
* the data path (db-publisher's `PublisherConnection`) resolve against the same
|
|
629
|
+
* remote connection. Note the two halves derive their URL differently: the
|
|
630
|
+
* db-publisher client re-parses `connectionUri` and rebuilds it through its
|
|
631
|
+
* generated client, while this introspection path appends to it verbatim — the
|
|
632
|
+
* remote must serve both forms for a given `connectionUri`. Keep them pointed at
|
|
633
|
+
* the same connection resource so they stay in agreement.
|
|
634
|
+
*/
|
|
635
|
+
async function fetchFromPublisherDataplane<T>(
|
|
636
|
+
connection: ApiConnection,
|
|
637
|
+
pathSuffix: string,
|
|
638
|
+
): Promise<T> {
|
|
639
|
+
const publisher = connection.publisherConnection;
|
|
640
|
+
// Type-narrowing guard only — the user-facing validation (with the
|
|
641
|
+
// actionable `Fix:` message) lives in validateConnectionShape, which every
|
|
642
|
+
// connection passes before it can be assembled and introspected here.
|
|
643
|
+
if (!publisher?.connectionUri) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`Publisher connection "${connection.name}" is missing connectionUri`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
const url = `${publisher.connectionUri.replace(/\/+$/, "")}${pathSuffix}`;
|
|
649
|
+
const headers: Record<string, string> = {};
|
|
650
|
+
if (publisher.accessToken) {
|
|
651
|
+
headers["Authorization"] = `Bearer ${publisher.accessToken}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let response: Response;
|
|
655
|
+
try {
|
|
656
|
+
response = await fetch(url, {
|
|
657
|
+
headers,
|
|
658
|
+
signal: AbortSignal.timeout(PUBLISHER_INTROSPECTION_TIMEOUT_MS),
|
|
659
|
+
});
|
|
660
|
+
} catch (error) {
|
|
661
|
+
// Network-level failure (DNS, connection refused, timeout) — no HTTP
|
|
662
|
+
// status. Surface it with the connection name, like the other
|
|
663
|
+
// introspectors, and never echo the token-bearing header.
|
|
664
|
+
const reason =
|
|
665
|
+
(error as Error)?.name === "TimeoutError"
|
|
666
|
+
? `timed out after ${PUBLISHER_INTROSPECTION_TIMEOUT_MS}ms`
|
|
667
|
+
: (error as Error).message;
|
|
668
|
+
throw new Error(
|
|
669
|
+
`Publisher dataplane request to ${redactUrlCredentials(url)} failed ` +
|
|
670
|
+
`for connection "${connection.name}": ${reason}`,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (!response.ok) {
|
|
675
|
+
const body = await response.text().catch(() => "");
|
|
676
|
+
throw new Error(
|
|
677
|
+
`Publisher dataplane request to ${redactUrlCredentials(url)} failed ` +
|
|
678
|
+
`(${response.status}): ${body.slice(0, 200)}`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
return (await response.json()) as T;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function getSchemasForPublisher(
|
|
685
|
+
connection: ApiConnection,
|
|
686
|
+
): Promise<ApiSchema[]> {
|
|
687
|
+
return fetchFromPublisherDataplane<ApiSchema[]>(connection, "/schemas");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function listTablesForPublisher(
|
|
691
|
+
connection: ApiConnection,
|
|
692
|
+
schemaName: string,
|
|
693
|
+
tableNames?: string[],
|
|
694
|
+
): Promise<ApiTable[]> {
|
|
695
|
+
const tables = await fetchFromPublisherDataplane<ApiTable[]>(
|
|
696
|
+
connection,
|
|
697
|
+
`/schemas/${encodeURIComponent(schemaName)}/tables`,
|
|
698
|
+
);
|
|
699
|
+
if (!tableNames) {
|
|
700
|
+
return tables;
|
|
701
|
+
}
|
|
702
|
+
// `tableNames` is a bare-table-name filter (see listTablesForBigQuery). The
|
|
703
|
+
// remote's `resource` is dotted and its qualification depends on the remote
|
|
704
|
+
// dialect ("<schema>.<table>" or "<catalog>.<schema>.<table>"), so match the
|
|
705
|
+
// bare name (last segment) as well as the schema-stripped and full forms. An
|
|
706
|
+
// unrecognized shape still matches on the full resource rather than being
|
|
707
|
+
// silently dropped.
|
|
708
|
+
const allowed = new Set(tableNames);
|
|
709
|
+
const prefix = `${schemaName}.`;
|
|
710
|
+
return tables.filter((table) => {
|
|
711
|
+
const resource = table.resource ?? "";
|
|
712
|
+
const schemaStripped = resource.startsWith(prefix)
|
|
713
|
+
? resource.slice(prefix.length)
|
|
714
|
+
: resource;
|
|
715
|
+
const bareName = resource.slice(resource.lastIndexOf(".") + 1);
|
|
716
|
+
return (
|
|
717
|
+
allowed.has(bareName) ||
|
|
718
|
+
allowed.has(schemaStripped) ||
|
|
719
|
+
allowed.has(resource)
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
597
724
|
export async function getSchemasForConnection(
|
|
598
725
|
connection: ApiConnection,
|
|
599
726
|
malloyConnection: Connection,
|
|
@@ -617,6 +744,8 @@ export async function getSchemasForConnection(
|
|
|
617
744
|
return getSchemasForMotherDuck(connection, malloyConnection);
|
|
618
745
|
case "ducklake":
|
|
619
746
|
return getSchemasForDuckLake(connection, malloyConnection);
|
|
747
|
+
case "publisher":
|
|
748
|
+
return getSchemasForPublisher(connection);
|
|
620
749
|
default:
|
|
621
750
|
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
622
751
|
}
|
|
@@ -929,6 +1058,8 @@ export async function listTablesForSchema(
|
|
|
929
1058
|
malloyConnection,
|
|
930
1059
|
tableNames,
|
|
931
1060
|
);
|
|
1061
|
+
case "publisher":
|
|
1062
|
+
return listTablesForPublisher(connection, schemaName, tableNames);
|
|
932
1063
|
default:
|
|
933
1064
|
throw new Error(`Unsupported connection type: ${connection.type}`);
|
|
934
1065
|
}
|