diff --git a/package.json b/package.json index c7bfd1f5..ba555613 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zenstack-v3", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "ZenStack", "packageManager": "pnpm@10.12.1", "scripts": { diff --git a/packages/cli/package.json b/packages/cli/package.json index f31b0dd5..84fc7e70 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,7 +3,7 @@ "publisher": "zenstack", "displayName": "ZenStack CLI", "description": "FullStack database toolkit with built-in access control and automatic API generation.", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "type": "module", "author": { "name": "ZenStack Team" diff --git a/packages/common-helpers/package.json b/packages/common-helpers/package.json index 0a4b08d5..826dde3e 100644 --- a/packages/common-helpers/package.json +++ b/packages/common-helpers/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/common-helpers", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "ZenStack Common Helpers", "type": "module", "scripts": { diff --git a/packages/create-zenstack/package.json b/packages/create-zenstack/package.json index 4392b5a2..dcd6d334 100644 --- a/packages/create-zenstack/package.json +++ b/packages/create-zenstack/package.json @@ -1,6 +1,6 @@ { "name": "create-zenstack", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "Create a new ZenStack project", "type": "module", "scripts": { diff --git a/packages/dialects/sql.js/package.json b/packages/dialects/sql.js/package.json index eda0a36c..89f0aff5 100644 --- a/packages/dialects/sql.js/package.json +++ b/packages/dialects/sql.js/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/kysely-sql-js", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "Kysely dialect for sql.js", "type": "module", "scripts": { diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 0961cedf..c453274d 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/eslint-config", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "type": "module", "private": true, "license": "MIT" diff --git a/packages/ide/vscode/package.json b/packages/ide/vscode/package.json index 2fd0676f..8b183adc 100644 --- a/packages/ide/vscode/package.json +++ b/packages/ide/vscode/package.json @@ -1,7 +1,7 @@ { "name": "zenstack", "publisher": "zenstack", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "displayName": "ZenStack Language Tools", "description": "VSCode extension for ZenStack ZModel language", "private": true, diff --git a/packages/language/package.json b/packages/language/package.json index 89d188e1..e7af28d6 100644 --- a/packages/language/package.json +++ b/packages/language/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/language", "description": "ZenStack ZModel language specification", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "license": "MIT", "author": "ZenStack Team", "files": [ diff --git a/packages/runtime/package.json b/packages/runtime/package.json index a203a2b4..eab7dc0d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/runtime", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "ZenStack Runtime", "type": "module", "scripts": { diff --git a/packages/runtime/src/client/crud/dialects/base.ts b/packages/runtime/src/client/crud/dialects/base.ts index 5c84e5ff..8afec156 100644 --- a/packages/runtime/src/client/crud/dialects/base.ts +++ b/packages/runtime/src/client/crud/dialects/base.ts @@ -2,7 +2,7 @@ import { invariant, isPlainObject } from '@zenstackhq/common-helpers'; import type { Expression, ExpressionBuilder, ExpressionWrapper, SqlBool, ValueNode } from 'kysely'; import { expressionBuilder, sql, type SelectQueryBuilder } from 'kysely'; import { match, P } from 'ts-pattern'; -import type { BuiltinType, DataSourceProviderType, FieldDef, GetModels, SchemaDef } from '../../../schema'; +import type { BuiltinType, DataSourceProviderType, FieldDef, GetModels, ModelDef, SchemaDef } from '../../../schema'; import { enumerate } from '../../../utils/enumerate'; import type { OrArray } from '../../../utils/type-utils'; import { AGGREGATE_OPERATORS, DELEGATE_JOINED_FIELD_PREFIX, LOGICAL_COMBINATORS } from '../../constants'; @@ -963,6 +963,31 @@ export abstract class BaseCrudDialect { return result; } + protected buildModelSelect( + eb: ExpressionBuilder, + model: GetModels, + subQueryAlias: string, + payload: true | FindArgs, true>, + selectAllFields: boolean, + ) { + let subQuery = this.buildSelectModel(eb, model, subQueryAlias); + + if (selectAllFields) { + subQuery = this.buildSelectAllFields( + model, + subQuery, + typeof payload === 'object' ? payload?.omit : undefined, + subQueryAlias, + ); + } + + if (payload && typeof payload === 'object') { + subQuery = this.buildFilterSortTake(model, payload, subQuery, subQueryAlias); + } + + return subQuery; + } + buildSelectField( query: SelectQueryBuilder, model: string, @@ -1115,6 +1140,35 @@ export abstract class BaseCrudDialect { return buildFieldRef(this.schema, model, field, this.options, eb, modelAlias, inlineComputedField); } + protected canJoinWithoutNestedSelect( + modelDef: ModelDef, + payload: boolean | FindArgs, true>, + ) { + if (modelDef.computedFields) { + // computed fields requires explicit select + return false; + } + + if (modelDef.baseModel || modelDef.isDelegate) { + // delegate models require upward/downward joins + return false; + } + + if ( + typeof payload === 'object' && + (payload.orderBy || + payload.skip !== undefined || + payload.take !== undefined || + payload.cursor || + (payload as any).distinct) + ) { + // ordering/pagination/distinct needs to be handled before joining + return false; + } + + return true; + } + // #endregion // #region abstract methods diff --git a/packages/runtime/src/client/crud/dialects/postgresql.ts b/packages/runtime/src/client/crud/dialects/postgresql.ts index d3e6d48f..93722037 100644 --- a/packages/runtime/src/client/crud/dialects/postgresql.ts +++ b/packages/runtime/src/client/crud/dialects/postgresql.ts @@ -58,127 +58,151 @@ export class PostgresCrudDialect extends BaseCrudDiale parentAlias: string, payload: true | FindArgs, true>, ): SelectQueryBuilder { - const joinedQuery = this.buildRelationJSON(model, query, relationField, parentAlias, payload); - - return joinedQuery.select(`${parentAlias}$${relationField}.$j as ${relationField}`); + const relationResultName = `${parentAlias}$${relationField}`; + const joinedQuery = this.buildRelationJSON( + model, + query, + relationField, + parentAlias, + payload, + relationResultName, + ); + return joinedQuery.select(`${relationResultName}.$data as ${relationField}`); } private buildRelationJSON( model: string, qb: SelectQueryBuilder, relationField: string, - parentName: string, + parentAlias: string, payload: true | FindArgs, true>, + resultName: string, ) { const relationFieldDef = requireField(this.schema, model, relationField); const relationModel = relationFieldDef.type as GetModels; return qb.leftJoinLateral( (eb) => { - const joinTableName = `${parentName}$${relationField}`; - - // simple select by default - let result = eb.selectFrom(`${relationModel} as ${joinTableName}`); + const relationSelectName = `${resultName}$sub`; + const relationModelDef = requireModel(this.schema, relationModel); - // however if there're filter/orderBy/take/skip, - // we need to build a subquery to handle them before aggregation + let tbl: SelectQueryBuilder; - // give sub query an alias to avoid conflict with parent scope - // (e.g., for cases like self-relation) - const subQueryAlias = `${relationModel}$${relationField}$sub`; + if (this.canJoinWithoutNestedSelect(relationModelDef, payload)) { + // build join directly + tbl = this.buildModelSelect(eb, relationModel, relationSelectName, payload, false); - result = eb.selectFrom(() => { - let subQuery = this.buildSelectModel(eb, relationModel, subQueryAlias); - subQuery = this.buildSelectAllFields( + // parent join filter + tbl = this.buildRelationJoinFilter( + tbl, + model, + relationField, relationModel, - subQuery, - typeof payload === 'object' ? payload?.omit : undefined, - subQueryAlias, + relationSelectName, + parentAlias, ); - - if (payload && typeof payload === 'object') { - subQuery = this.buildFilterSortTake(relationModel, payload, subQuery, subQueryAlias); - } - - // add join conditions - - const m2m = getManyToManyRelation(this.schema, model, relationField); - - if (m2m) { - // many-to-many relation - const parentIds = getIdFields(this.schema, model); - const relationIds = getIdFields(this.schema, relationModel); - invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field'); - invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); - subQuery = subQuery.where( - eb( - eb.ref(`${subQueryAlias}.${relationIds[0]}`), - 'in', - eb - .selectFrom(m2m.joinTable) - .select(`${m2m.joinTable}.${m2m.otherFkName}`) - .whereRef( - `${parentName}.${parentIds[0]}`, - '=', - `${m2m.joinTable}.${m2m.parentFkName}`, - ), - ), + } else { + // join with a nested query + tbl = eb.selectFrom(() => { + let subQuery = this.buildModelSelect( + eb, + relationModel, + `${relationSelectName}$t`, + payload, + true, ); - } else { - const joinPairs = buildJoinPairs(this.schema, model, parentName, relationField, subQueryAlias); - subQuery = subQuery.where((eb) => - this.and(eb, ...joinPairs.map(([left, right]) => eb(sql.ref(left), '=', sql.ref(right)))), + + // parent join filter + subQuery = this.buildRelationJoinFilter( + subQuery, + model, + relationField, + relationModel, + `${relationSelectName}$t`, + parentAlias, ); - } - return subQuery.as(joinTableName); - }); + return subQuery.as(relationSelectName); + }); + } - result = this.buildRelationObjectSelect( + // select relation result + tbl = this.buildRelationObjectSelect( relationModel, - joinTableName, - relationField, + relationSelectName, relationFieldDef, - result, + tbl, payload, - parentName, + resultName, ); // add nested joins for each relation - result = this.buildRelationJoins(relationModel, relationField, result, payload, parentName); + tbl = this.buildRelationJoins(tbl, relationModel, relationSelectName, payload, resultName); // alias the join table - return result.as(joinTableName); + return tbl.as(resultName); }, (join) => join.onTrue(), ); } + private buildRelationJoinFilter( + query: SelectQueryBuilder, + model: string, + relationField: string, + relationModel: GetModels, + relationModelAlias: string, + parentAlias: string, + ) { + const m2m = getManyToManyRelation(this.schema, model, relationField); + if (m2m) { + // many-to-many relation + const parentIds = getIdFields(this.schema, model); + const relationIds = getIdFields(this.schema, relationModel); + invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field'); + invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); + query = query.where((eb) => + eb( + eb.ref(`${relationModelAlias}.${relationIds[0]}`), + 'in', + eb + .selectFrom(m2m.joinTable) + .select(`${m2m.joinTable}.${m2m.otherFkName}`) + .whereRef(`${parentAlias}.${parentIds[0]}`, '=', `${m2m.joinTable}.${m2m.parentFkName}`), + ), + ); + } else { + const joinPairs = buildJoinPairs(this.schema, model, parentAlias, relationField, relationModelAlias); + query = query.where((eb) => + this.and(eb, ...joinPairs.map(([left, right]) => eb(sql.ref(left), '=', sql.ref(right)))), + ); + } + return query; + } + private buildRelationObjectSelect( relationModel: string, relationModelAlias: string, - relationField: string, relationFieldDef: FieldDef, qb: SelectQueryBuilder, payload: true | FindArgs, true>, - parentName: string, + parentResultName: string, ) { qb = qb.select((eb) => { const objArgs = this.buildRelationObjectArgs( relationModel, relationModelAlias, - relationField, eb, payload, - parentName, + parentResultName, ); if (relationFieldDef.array) { return eb.fn .coalesce(sql`jsonb_agg(jsonb_build_object(${sql.join(objArgs)}))`, sql`'[]'::jsonb`) - .as('$j'); + .as('$data'); } else { - return sql`jsonb_build_object(${sql.join(objArgs)})`.as('$j'); + return sql`jsonb_build_object(${sql.join(objArgs)})`.as('$data'); } }); @@ -188,17 +212,15 @@ export class PostgresCrudDialect extends BaseCrudDiale private buildRelationObjectArgs( relationModel: string, relationModelAlias: string, - relationField: string, eb: ExpressionBuilder, payload: true | FindArgs, true>, - parentAlias: string, + parentResultName: string, ) { const relationModelDef = requireModel(this.schema, relationModel); const objArgs: Array< string | ExpressionWrapper | SelectQueryBuilder | RawBuilder > = []; - // TODO: descendant JSON shouldn't be joined and selected if none of its fields are selected const descendantModels = getDelegateDescendantModels(this.schema, relationModel); if (descendantModels.length > 0) { // select all JSONs built from delegate descendants @@ -234,7 +256,7 @@ export class PostgresCrudDialect extends BaseCrudDiale const subJson = this.buildCountJson( relationModel as GetModels, eb, - `${parentAlias}$${relationField}`, + relationModelAlias, value, ); return [sql.lit(field), subJson]; @@ -242,9 +264,9 @@ export class PostgresCrudDialect extends BaseCrudDiale const fieldDef = requireField(this.schema, relationModel, field); const fieldValue = fieldDef.relation ? // reference the synthesized JSON field - eb.ref(`${parentAlias}$${relationField}$${field}.$j`) + eb.ref(`${parentResultName}$${field}.$data`) : // reference a plain field - this.fieldRef(relationModel, field, eb, undefined, false); + this.fieldRef(relationModel, field, eb, relationModelAlias, false); return [sql.lit(field), fieldValue]; } }) @@ -260,7 +282,7 @@ export class PostgresCrudDialect extends BaseCrudDiale .map(([field]) => [ sql.lit(field), // reference the synthesized JSON field - eb.ref(`${parentAlias}$${relationField}$${field}.$j`), + eb.ref(`${parentResultName}$${field}.$data`), ]) .flatMap((v) => v), ); @@ -269,13 +291,13 @@ export class PostgresCrudDialect extends BaseCrudDiale } private buildRelationJoins( + query: SelectQueryBuilder, relationModel: string, - relationField: string, - qb: SelectQueryBuilder, + relationModelAlias: string, payload: true | FindArgs, true>, - parentName: string, + parentResultName: string, ) { - let result = qb; + let result = query; if (typeof payload === 'object') { const selectInclude = payload.include ?? payload.select; if (selectInclude && typeof selectInclude === 'object') { @@ -287,8 +309,9 @@ export class PostgresCrudDialect extends BaseCrudDiale relationModel, result, field, - `${parentName}$${relationField}`, + relationModelAlias, value, + `${parentResultName}$${field}`, ); }); } diff --git a/packages/runtime/src/client/crud/dialects/sqlite.ts b/packages/runtime/src/client/crud/dialects/sqlite.ts index c119c883..34ece56e 100644 --- a/packages/runtime/src/client/crud/dialects/sqlite.ts +++ b/packages/runtime/src/client/crud/dialects/sqlite.ts @@ -74,64 +74,39 @@ export class SqliteCrudDialect extends BaseCrudDialect const relationModelDef = requireModel(this.schema, relationModel); const subQueryName = `${parentAlias}$${relationField}`; + let tbl: SelectQueryBuilder; - let tbl = eb.selectFrom(() => { - // give sub query an alias to avoid conflict with parent scope - // (e.g., for cases like self-relation) - const subQueryAlias = `${parentAlias}$${relationField}$sub`; - let subQuery = this.buildSelectModel(eb, relationModel, subQueryAlias); + if (this.canJoinWithoutNestedSelect(relationModelDef, payload)) { + // join without needing a nested select on relation model + tbl = this.buildModelSelect(eb, relationModel, subQueryName, payload, false); - subQuery = this.buildSelectAllFields( - relationModel, - subQuery, - typeof payload === 'object' ? payload?.omit : undefined, - subQueryAlias, - ); - - if (payload && typeof payload === 'object') { - // take care of where, orderBy, skip, take, cursor, and distinct - subQuery = this.buildFilterSortTake(relationModel, payload, subQuery, subQueryAlias); - } + // add parent join filter + tbl = this.buildRelationJoinFilter(tbl, model, relationField, subQueryName, parentAlias); + } else { + // need to make a nested select on relation model + tbl = eb.selectFrom(() => { + // nested query name + const selectModelAlias = `${parentAlias}$${relationField}$sub`; - // join conditions + // select all fields + let selectModelQuery = this.buildModelSelect(eb, relationModel, selectModelAlias, payload, true); - const m2m = getManyToManyRelation(this.schema, model, relationField); - if (m2m) { - // many-to-many relation - const parentIds = getIdFields(this.schema, model); - const relationIds = getIdFields(this.schema, relationModel); - invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field'); - invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); - subQuery = subQuery.where( - eb( - eb.ref(`${subQueryAlias}.${relationIds[0]}`), - 'in', - eb - .selectFrom(m2m.joinTable) - .select(`${m2m.joinTable}.${m2m.otherFkName}`) - .whereRef(`${parentAlias}.${parentIds[0]}`, '=', `${m2m.joinTable}.${m2m.parentFkName}`), - ), + // add parent join filter + selectModelQuery = this.buildRelationJoinFilter( + selectModelQuery, + model, + relationField, + selectModelAlias, + parentAlias, ); - } else { - const { keyPairs, ownedByModel } = getRelationForeignKeyFieldPairs(this.schema, model, relationField); - keyPairs.forEach(({ fk, pk }) => { - if (ownedByModel) { - // the parent model owns the fk - subQuery = subQuery.whereRef(`${subQueryAlias}.${pk}`, '=', `${parentAlias}.${fk}`); - } else { - // the relation side owns the fk - subQuery = subQuery.whereRef(`${subQueryAlias}.${fk}`, '=', `${parentAlias}.${pk}`); - } - }); - } - return subQuery.as(subQueryName); - }); + return selectModelQuery.as(subQueryName); + }); + } tbl = tbl.select(() => { type ArgsType = Expression | RawBuilder | SelectQueryBuilder; const objArgs: ArgsType[] = []; - // TODO: descendant JSON shouldn't be joined and selected if none of its fields are selected const descendantModels = getDelegateDescendantModels(this.schema, relationModel); if (descendantModels.length > 0) { // select all JSONs built from delegate descendants @@ -151,7 +126,10 @@ export class SqliteCrudDialect extends BaseCrudDialect ...Object.entries(relationModelDef.fields) .filter(([, value]) => !value.relation) .filter(([name]) => !(typeof payload === 'object' && (payload.omit as any)?.[name] === true)) - .map(([field]) => [sql.lit(field), this.fieldRef(relationModel, field, eb, undefined, false)]) + .map(([field]) => [ + sql.lit(field), + this.fieldRef(relationModel, field, eb, subQueryName, false), + ]) .flatMap((v) => v), ); } else if (payload.select) { @@ -182,7 +160,7 @@ export class SqliteCrudDialect extends BaseCrudDialect } else { return [ sql.lit(field), - this.fieldRef(relationModel, field, eb, undefined, false) as ArgsType, + this.fieldRef(relationModel, field, eb, subQueryName, false) as ArgsType, ]; } } @@ -213,15 +191,65 @@ export class SqliteCrudDialect extends BaseCrudDialect if (relationFieldDef.array) { return eb.fn .coalesce(sql`json_group_array(json_object(${sql.join(objArgs)}))`, sql`json_array()`) - .as('$j'); + .as('$data'); } else { - return sql`json_object(${sql.join(objArgs)})`.as('data'); + return sql`json_object(${sql.join(objArgs)})`.as('$data'); } }); return tbl; } + private buildRelationJoinFilter( + selectModelQuery: SelectQueryBuilder, + model: string, + relationField: string, + relationModelAlias: string, + parentAlias: string, + ) { + const fieldDef = requireField(this.schema, model, relationField); + const relationModel = fieldDef.type as GetModels; + + const m2m = getManyToManyRelation(this.schema, model, relationField); + if (m2m) { + // many-to-many relation + const parentIds = getIdFields(this.schema, model); + const relationIds = getIdFields(this.schema, relationModel); + invariant(parentIds.length === 1, 'many-to-many relation must have exactly one id field'); + invariant(relationIds.length === 1, 'many-to-many relation must have exactly one id field'); + selectModelQuery = selectModelQuery.where((eb) => + eb( + eb.ref(`${relationModelAlias}.${relationIds[0]}`), + 'in', + eb + .selectFrom(m2m.joinTable) + .select(`${m2m.joinTable}.${m2m.otherFkName}`) + .whereRef(`${parentAlias}.${parentIds[0]}`, '=', `${m2m.joinTable}.${m2m.parentFkName}`), + ), + ); + } else { + const { keyPairs, ownedByModel } = getRelationForeignKeyFieldPairs(this.schema, model, relationField); + keyPairs.forEach(({ fk, pk }) => { + if (ownedByModel) { + // the parent model owns the fk + selectModelQuery = selectModelQuery.whereRef( + `${relationModelAlias}.${pk}`, + '=', + `${parentAlias}.${fk}`, + ); + } else { + // the relation side owns the fk + selectModelQuery = selectModelQuery.whereRef( + `${relationModelAlias}.${fk}`, + '=', + `${parentAlias}.${pk}`, + ); + } + }); + } + return selectModelQuery; + } + override buildSkipTake( query: SelectQueryBuilder, skip: number | undefined, diff --git a/packages/runtime/src/client/executor/name-mapper.ts b/packages/runtime/src/client/executor/name-mapper.ts index b2b96fda..cc8163c1 100644 --- a/packages/runtime/src/client/executor/name-mapper.ts +++ b/packages/runtime/src/client/executor/name-mapper.ts @@ -6,7 +6,6 @@ import { FromNode, IdentifierNode, InsertQueryNode, - JoinNode, OperationNodeTransformer, ReferenceNode, ReturningNode, @@ -22,15 +21,15 @@ import { getModel, requireModel } from '../query-utils'; import { stripAlias } from './kysely-utils'; type Scope = { - model: string; + model?: string; alias?: string; - namesMapped?: boolean; + namesMapped?: boolean; // true means fields referring to this scope have their names already mapped }; export class QueryNameMapper extends OperationNodeTransformer { private readonly modelToTableMap = new Map(); private readonly fieldToColumnMap = new Map(); - private readonly modelScopes: Scope[] = []; + private readonly scopes: Scope[] = []; constructor(private readonly schema: SchemaDef) { super(); @@ -56,16 +55,29 @@ export class QueryNameMapper extends OperationNodeTransformer { return super.transformSelectQuery(node); } - // all table names in "from" are pushed as scopes, each "from" is expanded - // as nested query to apply column name mapping, so the scopes are marked - // "namesMapped" so no additional name mapping is applied when resolving - // columns - const scopes = this.createScopesFromFroms(node.from, true); + // process "from" clauses + const processedFroms = node.from.froms.map((from) => this.processSelectTable(from)); + + // process "join" clauses + const processedJoins = (node.joins ?? []).map((join) => this.processSelectTable(join.table)); + + // merge the scopes of froms and joins since they're all visible in the query body + const scopes = [...processedFroms.map(({ scope }) => scope), ...processedJoins.map(({ scope }) => scope)]; + return this.withScopes(scopes, () => { + // transform join clauses, "on" is transformed within the scopes + const joins = node.joins + ? node.joins.map((join, i) => ({ + ...join, + table: processedJoins[i]!.node, + on: this.transformNode(join.on), + })) + : undefined; return { ...super.transformSelectQuery(node), - // convert "from" to nested query as needed - from: this.processFrom(node.from!), + from: FromNode.create(processedFroms.map((f) => f.node)), + joins, + selections: this.processSelectQuerySelections(node), }; }); } @@ -94,32 +106,16 @@ export class QueryNameMapper extends OperationNodeTransformer { }; } - protected override transformJoin(node: JoinNode) { - const { alias, node: innerNode } = stripAlias(node.table); - if (TableNode.is(innerNode!)) { - const modelName = innerNode.table.identifier.name; - if (this.hasMappedColumns(modelName)) { - // create a nested query with all fields selected and names mapped - const select = this.createSelectAll(modelName); - return { ...super.transformJoin(node), table: this.wrapAlias(select, alias ?? modelName) }; - } - } - return super.transformJoin(node); - } - protected override transformReference(node: ReferenceNode) { if (!ColumnNode.is(node.column)) { return super.transformReference(node); } // resolve the reference to a field from outer scopes - const { fieldDef, modelDef, scope } = this.resolveFieldFromScopes( - node.column.column.name, - node.table?.table.identifier.name, - ); - if (fieldDef && !scope.namesMapped) { + const scope = this.resolveFieldFromScopes(node.column.column.name, node.table?.table.identifier.name); + if (scope && !scope.namesMapped && scope.model) { // map column name and table name as needed - const mappedFieldName = this.mapFieldName(modelDef.name, fieldDef.name); + const mappedFieldName = this.mapFieldName(scope.model, node.column.column.name); // map table name depending on how it is resolved let mappedTableName = node.table?.table.identifier.name; @@ -142,11 +138,11 @@ export class QueryNameMapper extends OperationNodeTransformer { } protected override transformColumn(node: ColumnNode) { - const { modelDef, fieldDef, scope } = this.resolveFieldFromScopes(node.column.name); - if (!fieldDef || scope.namesMapped) { + const scope = this.resolveFieldFromScopes(node.column.name); + if (!scope || scope.namesMapped || !scope.model) { return super.transformColumn(node); } - const mappedName = this.mapFieldName(modelDef.name, fieldDef.name); + const mappedName = this.mapFieldName(scope.model, node.column.name); return ColumnNode.create(mappedName); } @@ -171,7 +167,14 @@ export class QueryNameMapper extends OperationNodeTransformer { protected override transformDeleteQuery(node: DeleteQueryNode) { // all "from" nodes are pushed as scopes - const scopes = this.createScopesFromFroms(node.from, false); + const scopes: Scope[] = node.from.froms.map((node) => { + const { alias, node: innerNode } = stripAlias(node); + return { + model: this.extractModelName(innerNode), + alias, + namesMapped: false, + }; + }); // process name mapping in each "from" const froms = node.from.froms.map((from) => { @@ -196,32 +199,82 @@ export class QueryNameMapper extends OperationNodeTransformer { // #region utils + private processSelectQuerySelections(node: SelectQueryNode) { + const selections: SelectionNode[] = []; + for (const selection of node.selections ?? []) { + if (SelectAllNode.is(selection.selection)) { + // expand `selectAll` to all fields with name mapping if the + // inner-most scope is not already mapped + const scope = this.scopes[this.scopes.length - 1]; + if (scope?.model && !scope.namesMapped) { + selections.push(...this.createSelectAllFields(scope.model, scope.alias)); + } else { + selections.push(super.transformSelection(selection)); + } + } else if (ReferenceNode.is(selection.selection) || ColumnNode.is(selection.selection)) { + // map column name and add/preserve alias + const transformed = this.transformNode(selection.selection); + if (AliasNode.is(transformed)) { + // keep the alias if there's one + selections.push(SelectionNode.create(transformed)); + } else { + // otherwise use an alias to preserve the original field name + const origFieldName = this.extractFieldName(selection.selection); + const fieldName = this.extractFieldName(transformed); + if (fieldName !== origFieldName) { + selections.push(SelectionNode.create(this.wrapAlias(transformed, origFieldName))); + } else { + selections.push(SelectionNode.create(transformed)); + } + } + } else { + selections.push(super.transformSelection(selection)); + } + } + return selections; + } + private resolveFieldFromScopes(name: string, qualifier?: string) { - for (const scope of this.modelScopes.toReversed()) { + for (let i = this.scopes.length - 1; i >= 0; i--) { + const scope = this.scopes[i]!; if (qualifier) { + // if the field as a qualifier, the qualifier must match the scope's + // alias if any, or model if no alias if (scope.alias) { - if (qualifier !== scope.alias) { + if (scope.alias === qualifier) { + // scope has an alias that matches the qualifier + return scope; + } else { + // scope has an alias but it doesn't match the qualifier continue; } - } else { - if (qualifier !== scope.model) { + } else if (scope.model) { + if (scope.model === qualifier) { + // scope has a model that matches the qualifier + return scope; + } else { + // scope has a model but it doesn't match the qualifier continue; } } - } - const modelDef = getModel(this.schema, scope.model); - if (!modelDef) { - continue; - } - if (modelDef.fields[name]) { - return { modelDef, fieldDef: modelDef.fields[name], scope }; + } else { + // if the field has no qualifier, match with model name + if (scope.model) { + const modelDef = getModel(this.schema, scope.model); + if (!modelDef) { + continue; + } + if (modelDef.fields[name]) { + return scope; + } + } } } - return { modelDef: undefined, fieldDef: undefined, scope: undefined }; + return undefined; } private pushScope(scope: Scope) { - this.modelScopes.push(scope); + this.scopes.push(scope); } private withScope(scope: Scope, fn: (...args: unknown[]) => T): T { @@ -229,7 +282,7 @@ export class QueryNameMapper extends OperationNodeTransformer { try { return fn(); } finally { - this.modelScopes.pop(); + this.scopes.pop(); } } @@ -238,7 +291,7 @@ export class QueryNameMapper extends OperationNodeTransformer { try { return fn(); } finally { - scopes.forEach(() => this.modelScopes.pop()); + scopes.forEach(() => this.scopes.pop()); } } @@ -246,15 +299,6 @@ export class QueryNameMapper extends OperationNodeTransformer { return alias ? AliasNode.create(node, IdentifierNode.create(alias)) : node; } - private ensureAlias(node: OperationNode, alias: string | undefined, fallbackName: string) { - if (!node) { - return node; - } - return alias - ? AliasNode.create(node, IdentifierNode.create(alias)) - : AliasNode.create(node, IdentifierNode.create(fallbackName)); - } - private processTableRef(node: TableNode) { if (!node) { return node; @@ -298,64 +342,53 @@ export class QueryNameMapper extends OperationNodeTransformer { return [...this.fieldToColumnMap.keys()].some((key) => key.startsWith(modelName + '.')); } - private createScopesFromFroms(node: FromNode | undefined, namesMapped: boolean) { - if (!node) { - return []; - } - return node.froms - .map((from) => { - const { alias, node: innerNode } = stripAlias(from); - if (innerNode && TableNode.is(innerNode)) { - return { model: innerNode.table.identifier.name, alias, namesMapped }; - } else { - return undefined; - } - }) - .filter((s) => !!s); - } - // convert a "from" node to a nested query if there are columns with name mapping - private processFrom(node: FromNode): FromNode { - return { - ...super.transformFrom(node), - froms: node.froms.map((from) => { - const { alias, node: innerNode } = stripAlias(from); - if (!innerNode) { - return super.transformNode(from); - } - if (TableNode.is(innerNode)) { - if (this.hasMappedColumns(innerNode.table.identifier.name)) { - // create a nested query with all fields selected and names mapped - const selectAll = this.createSelectAll(innerNode.table.identifier.name); - - // use the original alias or table name as the alias for the nested query - // so its transparent to the outer scope - return this.ensureAlias(selectAll, alias, innerNode.table.identifier.name); - } - } - return this.transformNode(from); - }), - }; + private processSelectTable(node: OperationNode): { node: OperationNode; scope: Scope } { + const { alias, node: innerNode } = stripAlias(node); + if (innerNode && TableNode.is(innerNode)) { + // if the selection is a table, map its name and create alias to preserve model name, + // mark the scope as names NOT mapped if the model has field name mappings, so that + // inner transformations will map column names + const modelName = innerNode.table.identifier.name; + const mappedName = this.mapTableName(modelName); + const finalAlias = alias ?? (mappedName !== modelName ? modelName : undefined); + return { + node: this.wrapAlias(TableNode.create(mappedName), finalAlias), + scope: { + alias: alias ?? modelName, + model: modelName, + namesMapped: !this.hasMappedColumns(modelName), + }, + }; + } else { + // otherwise, it's an alias or a sub-query, in which case the inner field names are + // already mapped, so we just create a scope with the alias and mark names mapped + return { + node: super.transformNode(node), + scope: { + alias, + model: undefined, + namesMapped: true, + }, + }; + } } - // create a `SelectQueryNode` for the given model with all columns mapped - private createSelectAll(model: string): SelectQueryNode { + private createSelectAllFields(model: string, alias: string | undefined) { const modelDef = requireModel(this.schema, model); - const tableName = this.mapTableName(model); - return { - kind: 'SelectQueryNode', - from: FromNode.create([TableNode.create(tableName)]), - selections: this.getModelFields(modelDef).map((fieldDef) => { - const columnName = this.mapFieldName(model, fieldDef.name); - const columnRef = ReferenceNode.create(ColumnNode.create(columnName), TableNode.create(tableName)); - if (columnName !== fieldDef.name) { - const aliased = AliasNode.create(columnRef, IdentifierNode.create(fieldDef.name)); - return SelectionNode.create(aliased); - } else { - return SelectionNode.create(columnRef); - } - }), - }; + return this.getModelFields(modelDef).map((fieldDef) => { + const columnName = this.mapFieldName(model, fieldDef.name); + const columnRef = ReferenceNode.create( + ColumnNode.create(columnName), + alias ? TableNode.create(alias) : undefined, + ); + if (columnName !== fieldDef.name) { + const aliased = AliasNode.create(columnRef, IdentifierNode.create(fieldDef.name)); + return SelectionNode.create(aliased); + } else { + return SelectionNode.create(columnRef); + } + }); } private getModelFields(modelDef: ModelDef) { @@ -392,10 +425,10 @@ export class QueryNameMapper extends OperationNodeTransformer { } private processSelectAll(node: SelectAllNode) { - const scope = this.modelScopes[this.modelScopes.length - 1]; + const scope = this.scopes[this.scopes.length - 1]; invariant(scope); - if (!this.hasMappedColumns(scope.model)) { + if (!scope.model || !this.hasMappedColumns(scope.model)) { // no name mapping needed, preserve the select all return super.transformSelectAll(node); } @@ -403,12 +436,17 @@ export class QueryNameMapper extends OperationNodeTransformer { // expand select all to a list of selections with name mapping const modelDef = requireModel(this.schema, scope.model); return this.getModelFields(modelDef).map((fieldDef) => { - const columnName = this.mapFieldName(scope.model, fieldDef.name); + const columnName = this.mapFieldName(modelDef.name, fieldDef.name); const columnRef = ReferenceNode.create(ColumnNode.create(columnName)); return columnName !== fieldDef.name ? this.wrapAlias(columnRef, fieldDef.name) : columnRef; }); } + private extractModelName(node: OperationNode): string | undefined { + const { node: innerNode } = stripAlias(node); + return TableNode.is(innerNode!) ? innerNode!.table.identifier.name : undefined; + } + private extractFieldName(node: ReferenceNode | ColumnNode) { if (ReferenceNode.is(node) && ColumnNode.is(node.column)) { return node.column.column.name; diff --git a/packages/runtime/src/client/helpers/schema-db-pusher.ts b/packages/runtime/src/client/helpers/schema-db-pusher.ts index bbfa2a3e..781c131d 100644 --- a/packages/runtime/src/client/helpers/schema-db-pusher.ts +++ b/packages/runtime/src/client/helpers/schema-db-pusher.ts @@ -73,7 +73,9 @@ export class SchemaDbPusher { } private createModelTable(kysely: ToKysely, modelDef: ModelDef) { - let table: CreateTableBuilder = kysely.schema.createTable(modelDef.name).ifNotExists(); + let table: CreateTableBuilder = kysely.schema + .createTable(this.getTableName(modelDef)) + .ifNotExists(); for (const [fieldName, fieldDef] of Object.entries(modelDef.fields)) { if (fieldDef.originModel && !fieldDef.id) { @@ -106,6 +108,28 @@ export class SchemaDbPusher { return table; } + private getTableName(modelDef: ModelDef) { + const mapAttr = modelDef.attributes?.find((a) => a.name === '@@map'); + if (mapAttr && mapAttr.args?.[0]) { + const mappedName = ExpressionUtils.getLiteralValue(mapAttr.args[0].value); + if (mappedName) { + return mappedName as string; + } + } + return modelDef.name; + } + + private getColumnName(fieldDef: FieldDef) { + const mapAttr = fieldDef.attributes?.find((a) => a.name === '@map'); + if (mapAttr && mapAttr.args?.[0]) { + const mappedName = ExpressionUtils.getLiteralValue(mapAttr.args[0].value); + if (mappedName) { + return mappedName as string; + } + } + return fieldDef.name; + } + private isComputedField(fieldDef: FieldDef) { return fieldDef.attributes?.some((a) => a.name === '@computed'); } @@ -119,7 +143,10 @@ export class SchemaDbPusher { } if (modelDef.idFields.length > 0) { - table = table.addPrimaryKeyConstraint(`pk_${modelDef.name}`, modelDef.idFields); + table = table.addPrimaryKeyConstraint( + `pk_${modelDef.name}`, + modelDef.idFields.map((f) => this.getColumnName(modelDef.fields[f]!)), + ); } return table; @@ -134,17 +161,20 @@ export class SchemaDbPusher { if (fieldDef.unique) { continue; } - table = table.addUniqueConstraint(`unique_${modelDef.name}_${key}`, [key]); + table = table.addUniqueConstraint(`unique_${modelDef.name}_${key}`, [this.getColumnName(fieldDef)]); } else { // multi-field constraint - table = table.addUniqueConstraint(`unique_${modelDef.name}_${key}`, Object.keys(value)); + table = table.addUniqueConstraint( + `unique_${modelDef.name}_${key}`, + Object.keys(value).map((f) => this.getColumnName(modelDef.fields[f]!)), + ); } } return table; } private createModelField(table: CreateTableBuilder, fieldDef: FieldDef, modelDef: ModelDef) { - return table.addColumn(fieldDef.name, this.mapFieldType(fieldDef), (col) => { + return table.addColumn(this.getColumnName(fieldDef), this.mapFieldType(fieldDef), (col) => { // @id if (fieldDef.id && modelDef.idFields.length === 1) { col = col.primaryKey(); @@ -240,11 +270,14 @@ export class SchemaDbPusher { return table; } + const modelDef = requireModel(this.schema, model); + const relationModelDef = requireModel(this.schema, fieldDef.type); + table = table.addForeignKeyConstraint( `fk_${model}_${fieldName}`, - fieldDef.relation.fields, - fieldDef.type, - fieldDef.relation.references, + fieldDef.relation.fields.map((f) => this.getColumnName(modelDef.fields[f]!)), + this.getTableName(relationModelDef), + fieldDef.relation.references.map((f) => this.getColumnName(relationModelDef.fields[f]!)), (cb) => { if (fieldDef.relation?.onDelete) { cb = cb.onDelete(this.mapCascadeAction(fieldDef.relation.onDelete)); diff --git a/packages/runtime/src/schema/expression.ts b/packages/runtime/src/schema/expression.ts index 6ae1c158..a650391a 100644 --- a/packages/runtime/src/schema/expression.ts +++ b/packages/runtime/src/schema/expression.ts @@ -109,4 +109,8 @@ export const ExpressionUtils = { isField: (value: unknown): value is FieldExpression => ExpressionUtils.is(value, 'field'), isMember: (value: unknown): value is MemberExpression => ExpressionUtils.is(value, 'member'), + + getLiteralValue: (expr: Expression): string | number | boolean | undefined => { + return ExpressionUtils.isLiteral(expr) ? expr.value : undefined; + }, }; diff --git a/packages/runtime/test/client-api/name-mapping.test.ts b/packages/runtime/test/client-api/name-mapping.test.ts index 7904ee8f..41341f7c 100644 --- a/packages/runtime/test/client-api/name-mapping.test.ts +++ b/packages/runtime/test/client-api/name-mapping.test.ts @@ -235,5 +235,201 @@ describe.each([{ provider: 'sqlite' as const }, { provider: 'postgresql' as cons posts: [], }); }); + + it('works with count', async () => { + await db.user.create({ + data: { + email: 'u1@test.com', + posts: { + create: [{ title: 'Post1' }, { title: 'Post2' }], + }, + }, + }); + + await db.user.create({ + data: { + email: 'u2@test.com', + posts: { + create: [{ title: 'Post3' }], + }, + }, + }); + + // Test ORM count operations + await expect(db.user.count()).resolves.toBe(2); + await expect(db.post.count()).resolves.toBe(3); + await expect(db.user.count({ select: { email: true } })).resolves.toMatchObject({ + email: 2, + }); + + await expect(db.user.count({ where: { email: 'u1@test.com' } })).resolves.toBe(1); + await expect(db.post.count({ where: { title: { contains: 'Post1' } } })).resolves.toBe(1); + + await expect(db.post.count({ where: { author: { email: 'u1@test.com' } } })).resolves.toBe(2); + + // Test Kysely count operations + const r = await db.$qb + .selectFrom('User') + .select((eb) => eb.fn.count('email').as('count')) + .executeTakeFirst(); + await expect(Number(r?.count)).toBe(2); + }); + + it('works with aggregate', async () => { + await db.user.create({ + data: { + id: 1, + email: 'u1@test.com', + posts: { + create: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + ], + }, + }, + }); + + await db.user.create({ + data: { + id: 2, + email: 'u2@test.com', + posts: { + create: [{ id: 3, title: 'Post3' }], + }, + }, + }); + + // Test ORM aggregate operations + await expect(db.user.aggregate({ _count: { id: true, email: true } })).resolves.toMatchObject({ + _count: { id: 2, email: 2 }, + }); + + await expect( + db.post.aggregate({ _count: { authorId: true }, _min: { authorId: true }, _max: { authorId: true } }), + ).resolves.toMatchObject({ + _count: { authorId: 3 }, + _min: { authorId: 1 }, + _max: { authorId: 2 }, + }); + + await expect( + db.post.aggregate({ + where: { author: { email: 'u1@test.com' } }, + _count: { authorId: true }, + _min: { authorId: true }, + _max: { authorId: true }, + }), + ).resolves.toMatchObject({ + _count: { authorId: 2 }, + _min: { authorId: 1 }, + _max: { authorId: 1 }, + }); + + // Test Kysely aggregate operations + const countResult = await db.$qb + .selectFrom('User') + .select((eb) => eb.fn.count('email').as('emailCount')) + .executeTakeFirst(); + expect(Number(countResult?.emailCount)).toBe(2); + + const postAggResult = await db.$qb + .selectFrom('Post') + .select((eb) => [eb.fn.min('authorId').as('minAuthorId'), eb.fn.max('authorId').as('maxAuthorId')]) + .executeTakeFirst(); + expect(Number(postAggResult?.minAuthorId)).toBe(1); + expect(Number(postAggResult?.maxAuthorId)).toBe(2); + }); + + it('works with groupBy', async () => { + // Create test data with multiple posts per user + await db.user.create({ + data: { + id: 1, + email: 'u1@test.com', + posts: { + create: [ + { id: 1, title: 'Post1' }, + { id: 2, title: 'Post2' }, + { id: 3, title: 'Post3' }, + ], + }, + }, + }); + + await db.user.create({ + data: { + id: 2, + email: 'u2@test.com', + posts: { + create: [ + { id: 4, title: 'Post4' }, + { id: 5, title: 'Post5' }, + ], + }, + }, + }); + + await db.user.create({ + data: { + id: 3, + email: 'u3@test.com', + posts: { + create: [{ id: 6, title: 'Post6' }], + }, + }, + }); + + // Test ORM groupBy operations + const userGroupBy = await db.user.groupBy({ + by: ['email'], + _count: { id: true }, + }); + expect(userGroupBy).toHaveLength(3); + expect(userGroupBy).toEqual( + expect.arrayContaining([ + { email: 'u1@test.com', _count: { id: 1 } }, + { email: 'u2@test.com', _count: { id: 1 } }, + { email: 'u3@test.com', _count: { id: 1 } }, + ]), + ); + + const postGroupBy = await db.post.groupBy({ + by: ['authorId'], + _count: { id: true }, + _min: { id: true }, + _max: { id: true }, + }); + expect(postGroupBy).toHaveLength(3); + expect(postGroupBy).toEqual( + expect.arrayContaining([ + { authorId: 1, _count: { id: 3 }, _min: { id: 1 }, _max: { id: 3 } }, + { authorId: 2, _count: { id: 2 }, _min: { id: 4 }, _max: { id: 5 } }, + { authorId: 3, _count: { id: 1 }, _min: { id: 6 }, _max: { id: 6 } }, + ]), + ); + + const filteredGroupBy = await db.post.groupBy({ + by: ['authorId'], + where: { title: { contains: 'Post' } }, + _count: { title: true }, + having: { title: { _count: { gte: 2 } } }, + }); + expect(filteredGroupBy).toHaveLength(2); + expect(filteredGroupBy).toEqual( + expect.arrayContaining([ + { authorId: 1, _count: { title: 3 } }, + { authorId: 2, _count: { title: 2 } }, + ]), + ); + + // Test Kysely groupBy operations + const kyselyUserGroupBy = await db.$qb + .selectFrom('User') + .select(['email', (eb) => eb.fn.count('email').as('count')]) + .groupBy('email') + .having((eb) => eb.fn.count('email'), '>=', 1) + .execute(); + expect(kyselyUserGroupBy).toHaveLength(3); + }); }, ); diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 812b1581..cfc8c574 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/sdk", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "ZenStack SDK", "type": "module", "scripts": { diff --git a/packages/tanstack-query/package.json b/packages/tanstack-query/package.json index b02ac72f..f56ad049 100644 --- a/packages/tanstack-query/package.json +++ b/packages/tanstack-query/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/tanstack-query", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "", "main": "index.js", "type": "module", diff --git a/packages/testtools/package.json b/packages/testtools/package.json index abf2d199..83762c2a 100644 --- a/packages/testtools/package.json +++ b/packages/testtools/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/testtools", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "ZenStack Test Tools", "type": "module", "scripts": { diff --git a/packages/typescript-config/package.json b/packages/typescript-config/package.json index a4608771..bdc3b8fe 100644 --- a/packages/typescript-config/package.json +++ b/packages/typescript-config/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/typescript-config", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "private": true, "license": "MIT" } diff --git a/packages/vitest-config/package.json b/packages/vitest-config/package.json index b8cdcc57..34cb5524 100644 --- a/packages/vitest-config/package.json +++ b/packages/vitest-config/package.json @@ -1,7 +1,7 @@ { "name": "@zenstackhq/vitest-config", "type": "module", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "private": true, "license": "MIT", "exports": { diff --git a/packages/zod/package.json b/packages/zod/package.json index 32763180..5281b1fb 100644 --- a/packages/zod/package.json +++ b/packages/zod/package.json @@ -1,6 +1,6 @@ { "name": "@zenstackhq/zod", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "", "type": "module", "main": "index.js", diff --git a/samples/blog/package.json b/samples/blog/package.json index dc43e8b8..59ca3e91 100644 --- a/samples/blog/package.json +++ b/samples/blog/package.json @@ -1,6 +1,6 @@ { "name": "sample-blog", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "description": "", "main": "index.js", "scripts": { diff --git a/tests/e2e/package.json b/tests/e2e/package.json index 96b00cd4..e6ba74bb 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -1,6 +1,6 @@ { "name": "e2e", - "version": "3.0.0-alpha.32", + "version": "3.0.0-alpha.33", "private": true, "type": "module", "scripts": {