Files

684 lines
26 KiB
TypeScript

import { getEnv, getWorkspace, workerHasInternalServer } from "./client";
import { OpenAPI } from "./core/OpenAPI";
import { JobService } from "./services.gen";
type ResultCollection =
| "last_statement_all_rows"
| "last_statement_first_row"
| "last_statement_all_rows_scalar"
| "last_statement_first_row_scalar"
| "all_statements_all_rows"
| "all_statements_first_row"
| "all_statements_all_rows_scalar"
| "all_statements_first_row_scalar"
| "legacy";
type FetchParams<ResultCollectionT extends ResultCollection> = {
resultCollection?: ResultCollectionT;
};
type SqlResult<
T,
ResultCollectionT extends ResultCollection
> = ResultCollectionT extends "last_statement_first_row"
? T | null
: ResultCollectionT extends "all_statements_first_row"
? T[]
: ResultCollectionT extends "last_statement_all_rows"
? T[]
: ResultCollectionT extends "all_statements_all_rows"
? T[][]
: ResultCollectionT extends "last_statement_all_rows_scalar"
? T[keyof T][]
: ResultCollectionT extends "all_statements_all_rows_scalar"
? T[keyof T][][]
: ResultCollectionT extends "last_statement_first_row_scalar"
? T[keyof T] | null
: ResultCollectionT extends "all_statements_first_row_scalar"
? T[keyof T][]
: unknown;
/**
* SQL statement object with query content, arguments, and execution methods
*/
export type SqlStatement<T> = {
/** Raw SQL content with formatted arguments */
content: string;
/** Argument values keyed by parameter name */
args: Record<string, any>;
/**
* Execute the SQL query and return results
* @param params - Optional parameters including result collection mode
* @returns Query results based on the result collection mode
*/
fetch<ResultCollectionT extends ResultCollection = "last_statement_all_rows">(
params?: FetchParams<ResultCollectionT | ResultCollection> // The union is for auto-completion
): Promise<SqlResult<T, ResultCollectionT>>;
/**
* Execute the SQL query and return only the first row
* @param params - Optional parameters
* @returns First row of the query result
*/
fetchOne(
params?: Omit<FetchParams<"last_statement_first_row">, "resultCollection">
): Promise<SqlResult<T, "last_statement_first_row">>;
/**
* Execute the SQL query and return only the first row as a scalar value
* @param params - Optional parameters
* @returns First row of the query result
*/
fetchOneScalar(
params?: Omit<
FetchParams<"last_statement_first_row_scalar">,
"resultCollection"
>
): Promise<SqlResult<T, "last_statement_first_row_scalar">>;
/**
* Execute the SQL query without fetching rows
* @param params - Optional parameters
*/
execute(
params?: Omit<FetchParams<"last_statement_first_row">, "resultCollection">
): Promise<void>;
};
/**
* Wrapper for raw SQL fragments that should be inlined without parameterization.
* Created via `sql.raw(value)`.
*/
export class RawSql {
readonly __brand = "RawSql" as const;
constructor(public readonly value: string) { }
}
/**
* Template tag function for creating SQL statements with parameterized values
*/
export interface SqlTemplateFunction {
<T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;
/** Create a raw SQL fragment that will be inlined without parameterization */
raw(value: string): RawSql;
}
export interface DatatableSqlTemplateFunction extends SqlTemplateFunction {
query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;
}
// ---------------------------------------------------------------------------
// Provider interface — captures what differs between datatable and ducklake
// ---------------------------------------------------------------------------
interface SqlProvider {
formatArgDecl(argNum: number, argType: string): string;
formatArgUsage(
argNum: number,
explicitType: string | undefined,
inferredType: string
): string;
preamble(): string;
language: "postgresql" | "duckdb";
extraArgs: Record<string, any>;
providerName: string;
}
function datatableProvider(name: string, schema?: string): SqlProvider {
return {
providerName: "datatable",
language: "postgresql",
extraArgs: { database: `datatable://${name}` },
formatArgDecl: (argNum) => `-- $${argNum} arg${argNum}`,
formatArgUsage: (argNum, explicitType, inferredType) =>
explicitType !== undefined
? `$${argNum}`
: `$${argNum}::${inferredType}`,
preamble: () => (schema ? `SET search_path TO "${schema}";\n` : ""),
};
}
function ducklakeProvider(name: string, schema?: string): SqlProvider {
return {
providerName: "ducklake",
language: "duckdb",
extraArgs: {},
formatArgDecl: (argNum, argType) => `-- $arg${argNum} (${argType})`,
formatArgUsage: (argNum) => `$arg${argNum}`,
// `USE dl."schema"` sets the active schema so unqualified tables resolve there.
preamble: () =>
`ATTACH 'ducklake://${name}' AS dl;USE dl${schema ? `."${schema}"` : ""};\n`,
};
}
// ---------------------------------------------------------------------------
// Shared template function builder
// ---------------------------------------------------------------------------
// Build a ready-to-execute SqlStatement. Used by both the template-tag
// path (which builds `content` from strings/values) and `.query()` (which
// gets a hand-written SQL string with positional placeholders).
function buildSqlStatement(
provider: SqlProvider,
content: string,
contentBody: string,
args: Record<string, any>
): SqlStatement<any> {
async function fetch<ResultCollectionT extends ResultCollection>({
resultCollection,
}: FetchParams<ResultCollectionT> = {}) {
let finalContent = content;
if (resultCollection)
finalContent = `-- result_collection=${resultCollection}\n${finalContent}`;
try {
let result;
if (workerHasInternalServer()) {
result = await JobService.runScriptPreviewInline({
workspace: getWorkspace(),
requestBody: { args, content: finalContent, language: provider.language },
});
} else {
result = await JobService.runScriptPreviewAndWaitResult({
workspace: getWorkspace(),
requestBody: { args, content: finalContent, language: provider.language },
});
}
return result as SqlResult<any, ResultCollectionT>;
} catch (e: any) {
let err = e;
if (
e &&
typeof e.body == "string" &&
e.statusText == "Internal Server Error"
) {
let body = e.body;
if (body.startsWith("Internal:")) body = body.slice(9).trim();
if (body.startsWith("Error:")) body = body.slice(6).trim();
if (body.startsWith("datatable")) body = body.slice(9).trim();
err = Error(`${provider.providerName} ${body}`);
err.query = contentBody;
err.request = e.request;
}
throw err;
}
}
return {
content,
args,
fetch,
fetchOne: (params) =>
fetch({ ...params, resultCollection: "last_statement_first_row" }),
fetchOneScalar: (params) =>
fetch({
...params,
resultCollection: "last_statement_first_row_scalar",
}),
execute: (params) => fetch(params),
} satisfies SqlStatement<any>;
}
// JSON-encode a JS value into something the executor can deserialize. The
// JSON.stringify-friendly representation of a JS value before sending it to
// the executor:
// - `bigint` → string. JSON.stringify on a bigint throws; the
// executor accepts numeric strings into BIGINT
// slots via `Value::String → INT8`.
// - `Date` → ISO-8601 string. inferSqlType maps these to
// `TIMESTAMPTZ`; the executor's `Value::String`
// arm parses ISO strings into `chrono::DateTime`.
// - non-finite `number` → string ("NaN" / "Infinity" / "-Infinity").
// JSON.stringify renders these as `null`, which
// silently became NULL in the database. The
// executor accepts these literals via
// `Value::String → FLOAT8` (`f64::from_str`).
// - everything else → passed through unchanged.
function serializeArgValue(v: any): any {
if (typeof v === "bigint") return v.toString();
if (v instanceof Date) return v.toISOString();
if (typeof v === "number" && !Number.isFinite(v)) {
if (Number.isNaN(v)) return "NaN";
return v > 0 ? "Infinity" : "-Infinity";
}
return v;
}
function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction {
let sqlFn = ((strings: TemplateStringsArray, ...values: any[]) => {
// Separate raw vs parameterized values, assigning arg indices only to params
let argIndex = 0;
const valueInfos = values.map((v, i) => {
if (v instanceof RawSql)
return { raw: true as const, value: v.value, originalIndex: i };
argIndex++;
return {
raw: false as const,
value: v,
originalIndex: i,
argNum: argIndex,
};
});
// Arg declarations (SQL comments consumed by the executor)
let argDecls = valueInfos
.filter((info): info is Extract<(typeof valueInfos)[number], { raw: false }> => !info.raw)
.map((info) => {
let argType =
parseTypeAnnotation(
strings[info.originalIndex],
strings[info.originalIndex + 1]
) || inferSqlType(info.value);
return provider.formatArgDecl(info.argNum, argType);
});
let content = argDecls.length ? argDecls.join("\n") + "\n" : "";
content += provider.preamble();
// SQL body — inline raw values, reference params via provider syntax
let contentBody = "";
for (let i = 0; i < strings.length; i++) {
contentBody += strings[i];
if (i < valueInfos.length) {
let info = valueInfos[i];
if (info.raw) {
contentBody += info.value;
} else {
let explicitType = parseTypeAnnotation(
strings[info.originalIndex],
strings[info.originalIndex + 1]
);
let inferredType = inferSqlType(info.value);
contentBody += provider.formatArgUsage(
info.argNum,
explicitType,
inferredType
);
}
}
}
content += contentBody;
const args = {
...Object.fromEntries(
valueInfos
.filter((info): info is Extract<(typeof valueInfos)[number], { raw: false }> => !info.raw)
.map((info) => [`arg${info.argNum}`, serializeArgValue(info.value)])
),
...provider.extraArgs,
};
return buildSqlStatement(provider, content, contentBody, args);
}) as SqlTemplateFunction;
sqlFn.raw = (value: string) => new RawSql(value);
return sqlFn;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Create a SQL template function for PostgreSQL/datatable queries
* @param name - Database/datatable name (default: "main")
* @returns SQL template function for building parameterized queries
* @example
* let sql = wmill.datatable()
* let name = 'Robin'
* let age = 21
* await sql`
* SELECT * FROM friends
* WHERE name = ${name} AND age = ${age}::int
* `.fetch()
*/
export function datatable(name: string = "main"): DatatableSqlTemplateFunction {
let { name: n, schema } = parseName(name);
let provider = datatableProvider(n, schema);
let sqlFn = buildSqlTemplateFunction(provider) as DatatableSqlTemplateFunction;
// `.query(sql, ...params)` is for SQL strings that already contain
// positional placeholders ($1, $2, ...). We DON'T go through the template
// builder here — that would re-emit each value as `$N::TYPE` and append
// them after the user's literal SQL, which is the bug previous versions of
// this method shipped. Instead we build the executor-shaped content
// directly: a `-- $N argN (TYPE)` declaration block (the parser picks
// these up as explicitly typed args) followed by the user's SQL verbatim.
// Note: we hand-roll the decl format here rather than calling
// `provider.formatArgDecl`, because the datatable formatter intentionally
// omits the type (the template-tag path emits `$N::TYPE` inline instead);
// for `.query()` we have no inline cast to fall back on.
sqlFn.query = (sqlString: string, ...params: any[]) => {
let argDecls = params
.map((v, i) => `-- $${i + 1} arg${i + 1} (${inferSqlType(v)})`)
.join("\n");
let contentBody = sqlString;
let content =
(argDecls ? argDecls + "\n" : "") + provider.preamble() + sqlString;
let args = {
...Object.fromEntries(
params.map((v, i) => [`arg${i + 1}`, serializeArgValue(v)])
),
...provider.extraArgs,
};
return buildSqlStatement(provider, content, contentBody, args);
};
return sqlFn;
}
/**
* Create a SQL template function for DuckDB/ducklake queries
* @param name - DuckDB database name, optionally with a schema as `name:schema` (default: "main")
* @returns SQL template function for building parameterized queries
* @example
* let sql = wmill.ducklake()
* let name = 'Robin'
* let age = 21
* await sql`
* SELECT * FROM friends
* WHERE name = ${name} AND age = ${age}
* `.fetch()
* @example
* // Target a specific schema within the ducklake
* let sql = wmill.ducklake("my_lake:analytics")
*/
export function ducklake(name: string = "main"): SqlTemplateFunction {
let { name: n, schema } = parseName(name);
return buildSqlTemplateFunction(ducklakeProvider(n, schema));
}
/** Options for the ducklake materialization helpers. `partition` is bound as a
* DuckDB arg (never interpolated); `selectSql`, `table`, `schema`, `uniqueKey`
* are trusted structural SQL inlined via `raw`. */
export interface DucklakeMaterializeOptions {
/** ducklake name (default "main"), optionally "name:schema". */
ducklake?: string;
/** target table within the ducklake. */
table: string;
/** the SELECT producing the rows for this slice. */
selectSql: string;
/** the partition value (bound). Omit for a whole-table materialization — no
* partition column, and replace becomes a `CREATE OR REPLACE TABLE`. */
partition?: string;
/** dedup key → upsert in slice (delete-by-key + insert); omit → replace (delete partition + insert). */
uniqueKey?: string;
/** physical partition column (default "_wm_partition"). */
partitionCol?: string;
}
/** Idempotently materialize `selectSql` into a ducklake table for one
* partition (or the whole table when `partition` is omitted) — the client-side
* equivalent of the `// materialize` engine.
* With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it
* replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).
* Safe to re-run for the same partition (backfill / failure-recovery).
*
* Returns a lazy statement — call `.execute()` to run it:
* `await wmill.upsertPartition({ table, selectSql, partition }).execute()`. */
export function upsertPartition(opts: DucklakeMaterializeOptions): SqlStatement<any> {
return finishMaterialize(buildUpsertStatement(opts), opts);
}
function buildUpsertStatement(opts: DucklakeMaterializeOptions): SqlStatement<any> {
let { name: n, schema } = parseName(opts.ducklake ?? "main");
let sql = buildSqlTemplateFunction(ducklakeProvider(n, schema));
let pcol = sql.raw(opts.partitionCol ?? "_wm_partition");
let t = sql.raw(`dl.${opts.table}`);
let body = sql.raw(opts.selectSql);
// Whole-table (no partition): no partition column. Replace rebuilds the table
// with CREATE OR REPLACE (handles schema changes); merge upserts by key.
if (opts.partition === undefined) {
if (opts.uniqueKey) {
let uk = sql.raw(opts.uniqueKey);
return sql`CREATE TABLE IF NOT EXISTS ${t} AS SELECT * FROM (${body}) WHERE false;
BEGIN TRANSACTION;
DELETE FROM ${t} WHERE ${uk} IN (SELECT ${uk} FROM (${body}));
INSERT INTO ${t} SELECT * FROM (${body});
COMMIT;`;
}
return sql`CREATE OR REPLACE TABLE ${t} AS SELECT * FROM (${body});`;
}
if (opts.uniqueKey) {
let uk = sql.raw(opts.uniqueKey);
// Upsert via delete-by-key + insert (not MERGE — DuckLake's MERGE fails
// writing the first rows of a fresh partition).
return sql`CREATE TABLE IF NOT EXISTS ${t} AS SELECT *, CAST(NULL AS VARCHAR) AS ${pcol} FROM (${body}) WHERE false;
ALTER TABLE ${t} SET PARTITIONED BY (${pcol});
BEGIN TRANSACTION;
DELETE FROM ${t} WHERE ${pcol} = ${opts.partition} AND ${uk} IN (SELECT ${uk} FROM (${body}));
INSERT INTO ${t} SELECT *, ${opts.partition} AS ${pcol} FROM (${body});
COMMIT;`;
}
return sql`CREATE TABLE IF NOT EXISTS ${t} AS SELECT *, CAST(NULL AS VARCHAR) AS ${pcol} FROM (${body}) WHERE false;
ALTER TABLE ${t} SET PARTITIONED BY (${pcol});
BEGIN TRANSACTION;
DELETE FROM ${t} WHERE ${pcol} = ${opts.partition};
INSERT INTO ${t} SELECT *, ${opts.partition} AS ${pcol} FROM (${body});
COMMIT;`;
}
/** INSERT-only materialization (no dedup/replace) for append-only tables.
* Re-running the same partition duplicates rows — use only for immutable
* event-log sources.
*
* Returns a lazy statement — call `.execute()` to run it:
* `await wmill.appendPartition({ table, selectSql, partition }).execute()`. */
export function appendPartition(
opts: Omit<DucklakeMaterializeOptions, "uniqueKey">,
): SqlStatement<any> {
return finishMaterialize(buildAppendStatement(opts), opts);
}
function buildAppendStatement(
opts: Omit<DucklakeMaterializeOptions, "uniqueKey">,
): SqlStatement<any> {
let { name: n, schema } = parseName(opts.ducklake ?? "main");
let sql = buildSqlTemplateFunction(ducklakeProvider(n, schema));
let pcol = sql.raw(opts.partitionCol ?? "_wm_partition");
let t = sql.raw(`dl.${opts.table}`);
let body = sql.raw(opts.selectSql);
// Whole-table (no partition): insert into the bare table, no partition column.
if (opts.partition === undefined) {
return sql`CREATE TABLE IF NOT EXISTS ${t} AS SELECT * FROM (${body}) WHERE false;
INSERT INTO ${t} SELECT * FROM (${body});`;
}
return sql`CREATE TABLE IF NOT EXISTS ${t} AS SELECT *, CAST(NULL AS VARCHAR) AS ${pcol} FROM (${body}) WHERE false;
ALTER TABLE ${t} SET PARTITIONED BY (${pcol});
INSERT INTO ${t} SELECT *, ${opts.partition} AS ${pcol} FROM (${body});`;
}
// In pipeline context (WM_PIPELINE), wrap a materialize statement so a
// successful run captures the slice's row count + snapshot and records
// materialized_partition state — making SDK materializations appear in the grid
// like `// materialize` ones. Outside a pipeline it's a passthrough (no record).
function finishMaterialize(
stmt: SqlStatement<any>,
opts: Pick<DucklakeMaterializeOptions, "ducklake" | "table" | "partition" | "partitionCol">,
): SqlStatement<any> {
if (getEnv("WM_PIPELINE") !== "true") return stmt;
let { name: n, schema } = parseName(opts.ducklake ?? "main");
let sql = buildSqlTemplateFunction(ducklakeProvider(n, schema));
let t = sql.raw(`dl.${opts.table}`);
let pcol = opts.partitionCol ?? "_wm_partition";
let where =
opts.partition !== undefined
? sql.raw(`WHERE ${pcol} = '${String(opts.partition).replace(/'/g, "''")}'`)
: sql.raw("");
let summary = sql`SELECT (SELECT count(*) FROM ${t} ${where}) AS rows, (SELECT max(snapshot_id) FROM ducklake_snapshots('dl')) AS snapshot_id`;
// Asset path mirrors the `// materialize` engine: <lake>/<schema>.<table> for
// an explicit schema, else <lake>/<table> — so the grid lookup matches and
// distinct schemas don't collide under one state key.
let assetPath = schema ? `${n}/${schema}.${opts.table}` : `${n}/${opts.table}`;
let partition = opts.partition ?? "";
let run = async () => {
try {
await stmt.execute();
} catch (e) {
await recordMaterialization(assetPath, partition, "failed", null, null, String(e));
throw e;
}
let snapshot_id: number | null = null;
let row_count: number | null = null;
try {
let s: any = await summary.fetchOne();
snapshot_id = s?.snapshot_id ?? null;
row_count = s?.rows ?? null;
} catch {
/* summary read is best-effort */
}
await recordMaterialization(assetPath, partition, "materialized", snapshot_id, row_count, null);
};
return {
...stmt,
execute: (() => run()) as any,
fetch: (() => run()) as any,
fetchOne: (() => run()) as any,
fetchOneScalar: (() => run()) as any,
};
}
async function recordMaterialization(
assetPath: string,
partition: string,
status: string,
snapshot_id: number | null,
row_count: number | null,
error: string | null,
): Promise<void> {
try {
await fetch(`${OpenAPI.BASE}/w/${getWorkspace()}/assets/record_materialization`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${OpenAPI.TOKEN as string}`,
},
body: JSON.stringify({
asset_kind: "ducklake",
asset_path: assetPath,
partition,
status,
snapshot_id,
row_count,
job_id: getEnv("WM_JOB_ID") ?? null,
error,
}),
});
} catch {
// best-effort; never fail the user's materialization
}
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
// DuckDB executor requires explicit argument types at declaration
// And postgres at argument usage.
// These types exist in both DuckDB and Postgres
// Check that the types exist if you plan to extend this function for other SQL engines.
function inferSqlType(value: any): string {
if (typeof value === "bigint") return "BIGINT";
if (typeof value === "number") {
if (Number.isInteger(value)) return "BIGINT";
return "DOUBLE PRECISION";
} else if (value === null || value === undefined) {
return "TEXT";
} else if (typeof value === "string") {
return "TEXT";
} else if (Array.isArray(value)) {
// Homogeneous-primitive arrays auto-tag as `TYPE[]` so that values like
// `${[1,2,3]}` against an `int[]` column work without an explicit
// `${arr}::int[]` cast. For non-homogeneous or nested arrays we fall
// back to JSON, which works for jsonb columns.
return inferSqlArrayType(value);
} else if (value instanceof Date) {
// JS `Date` carries an absolute instant in UTC; map to TIMESTAMPTZ so
// `${someDate}` works against a `timestamptz` column without the user
// needing an explicit cast. Without this the typeof check above falls
// through to "object" → JSON, which only works by accident via
// PG's `json → text → timestamptz` implicit cast chain.
return "TIMESTAMPTZ";
} else if (typeof value === "object") {
return "JSON";
} else if (typeof value === "boolean") {
return "BOOLEAN";
} else {
return "TEXT";
}
}
function inferSqlArrayType(value: any[]): string {
if (value.length === 0) return "JSON";
// Detect a single shared scalar JS type across all elements. Mixed types
// or any non-primitive element forces the JSON fallback.
let scalarType: string | undefined = undefined;
for (const elem of value) {
let elemType: string;
if (typeof elem === "bigint") elemType = "BIGINT";
else if (typeof elem === "number")
elemType = Number.isInteger(elem) ? "BIGINT" : "DOUBLE PRECISION";
else if (typeof elem === "string") elemType = "TEXT";
else if (typeof elem === "boolean") elemType = "BOOLEAN";
else return "JSON";
if (scalarType === undefined) scalarType = elemType;
else if (scalarType === "BIGINT" && elemType === "DOUBLE PRECISION")
scalarType = "DOUBLE PRECISION";
else if (scalarType === "DOUBLE PRECISION" && elemType === "BIGINT") {
// already widened
} else if (scalarType !== elemType) {
return "JSON";
}
}
return `${scalarType}[]`;
}
// The goal is to detect if the user added a type annotation manually
//
// untyped : sql`SELECT ${x} = 0` => ['SELECT ', ' = 0']
// typed : sql`SELECT ${x}::int = 0` => ['SELECT ', '::int = 0']
// typed : sql`SELECT CAST ( ${x} AS int ) = 0` => ['SELECT CAST ( ', ' AS int ) = 0']
//
// Caveat: the returned string is only meaningful as a *presence* signal —
// the only consumer (`formatArgUsage`) just checks `explicitType !== undefined`
// to decide whether to emit `$N` (user already wrote a cast) vs `$N::TYPE`
// (SDK injects the inferred cast). The returned string itself can be
// imprecise — e.g. `${x}::DOUBLE PRECISION` returns `"DOUBLE"` (split on
// whitespace), and `CAST(${x} AS int)` returns `"int)"` (no paren stripping).
// Don't rely on the returned string as a parsed PG type; only on whether
// it's defined.
function parseTypeAnnotation(
prevTemplateString: string | undefined,
nextTemplateString: string | undefined
): string | undefined {
if (!nextTemplateString) return;
nextTemplateString = nextTemplateString.trimStart();
if (nextTemplateString.startsWith("::")) {
return nextTemplateString.substring(2).trimStart().split(/\s+/)[0];
}
prevTemplateString = prevTemplateString?.trimEnd();
if (
prevTemplateString?.endsWith("(") &&
prevTemplateString
.substring(0, prevTemplateString.length - 1)
.trim()
.toUpperCase()
.endsWith("CAST") &&
nextTemplateString.toUpperCase().startsWith("AS ")
) {
return nextTemplateString.substring(2).trimStart().split(/\s+/)[0];
}
}
function parseName(name: string | undefined): {
name: string;
schema?: string;
} {
if (!name) return { name: "main" };
let [assetName, schemaName] = name.split(":");
if (schemaName) {
return {
name: assetName || "main",
schema: schemaName,
};
} else {
return { name };
}
}