Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2a10bca
fix(orm): export UncheckedCreateInput/CheckedCreateInput and add XOR …
ymc9 Apr 29, 2026
9cef027
chore: upgrade to TypeScript 6 (#2629)
ymc9 Apr 30, 2026
67da884
fix(tanstack-query): support DbNull/JsonNull/AnyNull serialization ov…
ymc9 Apr 30, 2026
516a2a2
update test
ymc9 Apr 30, 2026
a31a32e
fix(tanstack-query): support DbNull/JsonNull/AnyNull serialization ov…
ymc9 Apr 30, 2026
22e0fd4
feat(tanstack-query): add useTransaction hook for sequential transact…
ymc9 May 4, 2026
679f91f
feat(orm): add fuzzy search and relevance ordering (PostgreSQL) (#2573)
docloulou May 4, 2026
eff4263
refactor(tanstack-query, orm): thread plugin generics through transac…
ymc9 May 5, 2026
8ddbfde
feat(orm): add field-level @fuzzy attribute to gate fuzzy search (#2642)
ymc9 May 5, 2026
090be2c
fix(zod): json type compatibility between inferred zod types and @zen…
Azzerty23 May 6, 2026
d5e7900
fix(orm, zod): allow null in inferred type of required Json fields (#…
ymc9 May 6, 2026
2ef5a99
refactor(orm): make ZenStackPromise compatible with standard Promise …
ymc9 May 6, 2026
f0fa5ea
chore: run test:generate during build for orm/schema/zod
ymc9 May 6, 2026
9d147b9
feat(fetch-client): implement fetch-based CRUD API client (#2651)
ymc9 May 6, 2026
d0dd954
feat(orm): add @fullText attribute and Postgres full-text search
ymc9 May 6, 2026
1997cf3
fix(orm): coalesce NULL → '' in single-field _ftsRelevance ORDER BY
ymc9 May 7, 2026
9bfc3fe
feat(orm): implement postgres full-text search (#2653)
ymc9 May 7, 2026
d1db37c
fix(orm): format Date as HH:MM:SS for @db.Time / @db.Timetz columns (…
erwan-joly May 8, 2026
7283d0e
fix(orm): handle cyclic JSON typedef references in zod factory (#2654…
ymc9 May 8, 2026
899e74d
test(regression): add regression test for issue #2639 (#2657)
ymc9 May 8, 2026
b53e908
[CI] Bump version 3.7.0 (#2656)
github-actions[bot] May 8, 2026
08c11e7
fix(better-auth): keep schema-generator import lazy in CJS output (#2…
ymc9 May 8, 2026
ce50d3b
fix(orm): coerce ISO strings on DateTime input, with strictDateInput …
erwan-joly May 8, 2026
1a4de21
fix: detect policy plugin by stable id (#2663)
Albatrosso May 12, 2026
79498da
test(fetch-client): restore globalThis.fetch in afterEach (#2668)
ymc9 May 12, 2026
e492c93
fix(cli): add missing opposite relation fields during db pull when mu…
svetch May 13, 2026
026450b
fix(orm): make orderBy `nulls` optional (#2670)
ymc9 May 13, 2026
ed01275
chore: bump Kysely to 0.29 (#2626)
ymc9 May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "zenstack-v3",
"displayName": "ZenStack",
"description": "ZenStack",
"version": "3.6.4",
"version": "3.7.0",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down Expand Up @@ -63,7 +63,8 @@
"overrides": {
"cookie@<0.7.0": ">=0.7.0",
"lodash-es@>=4.0.0 <=4.17.22": ">=4.17.23",
"lodash@>=4.0.0 <=4.17.22": ">=4.17.23"
"lodash@>=4.0.0 <=4.17.22": ">=4.17.23",
"@better-auth/core": "1.4.19"
}
},
"funding": "https://github.com/sponsors/zenstackhq"
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-adapters/better-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@zenstackhq/better-auth",
"displayName": "ZenStack Better Auth Adapter",
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
"version": "3.6.4",
"version": "3.7.0",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
9 changes: 5 additions & 4 deletions packages/auth-adapters/better-auth/src/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { BetterAuthOptions } from '@better-auth/core';
import type { DBAdapter, Where } from '@better-auth/core/db/adapter';
import type { BetterAuthOptions, Where } from 'better-auth';
import { BetterAuthError } from '@better-auth/core/error';
import type { ClientContract, ModelOperations, UpdateInput } from '@zenstackhq/orm';
import type { GetModels, SchemaDef } from '@zenstackhq/orm/schema';
Expand Down Expand Up @@ -187,7 +186,9 @@ export const zenstackAdapter = <Schema extends SchemaDef>(db: ClientContract<Sch
options: config,

createSchema: async ({ file, tables }) => {
const generateSchema = (await import('./schema-generator')).generateSchema;
// Self-import via package subpath (not a relative './schema-generator') so the
// bundler treats it as external and keeps it lazy in the CJS output — see tsdown.config.ts.
const generateSchema = (await import('@zenstackhq/better-auth/schema-generator')).generateSchema;
return generateSchema(file, tables, config, options);
},
};
Expand All @@ -213,7 +214,7 @@ export const zenstackAdapter = <Schema extends SchemaDef>(db: ClientContract<Sch
};

const adapter = createAdapterFactory(adapterOptions);
return (options: BetterAuthOptions): DBAdapter<BetterAuthOptions> => {
return (options: BetterAuthOptions) => {
lazyOptions = options;
return adapter(options);
};
Expand Down
6 changes: 5 additions & 1 deletion packages/auth-adapters/better-auth/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
"extends": "@zenstackhq/typescript-config/base.json",
"compilerOptions": {
"rootDir": ".",
"noPropertyAccessFromIndexSignature": false
"noPropertyAccessFromIndexSignature": false,
"types": ["node"],
"paths": {
"@zenstackhq/better-auth/schema-generator": ["./src/schema-generator.ts"]
}
},
"include": ["src/**/*"]
}
17 changes: 16 additions & 1 deletion packages/auth-adapters/better-auth/tsdown.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
import { createConfig } from '@zenstackhq/tsdown-config';

export default createConfig({ entry: { index: 'src/index.ts', 'schema-generator': 'src/schema-generator.ts' } });
// `index` and `schema-generator` are built as two separate tsdown invocations so that
// the lazy `await import('@zenstackhq/better-auth/schema-generator')` in the adapter
// stays lazy in the CJS output. When both entries live in a single build, Rolldown
// treats them as siblings and injects a top-level `require('./schema-generator.cjs')`
// into `index.cjs`, which eagerly pulls in `@zenstackhq/language` (Langium) at adapter
// load time. Splitting the builds hides that relationship; `neverBundle` then keeps
// the dynamic import as a package-name reference that Node resolves at first call.
export default [
createConfig({
entry: { index: 'src/index.ts' },
deps: { neverBundle: ['@zenstackhq/better-auth/schema-generator'] },
}),
createConfig({
entry: { 'schema-generator': 'src/schema-generator.ts' },
}),
];
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@zenstackhq/cli",
"displayName": "ZenStack CLI",
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
"version": "3.6.4",
"version": "3.7.0",
"type": "module",
"author": {
"name": "ZenStack Team",
Expand Down
105 changes: 71 additions & 34 deletions packages/cli/src/actions/db.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { formatDocument, ZModelCodeGenerator } from '@zenstackhq/language';
import { DataModel, Enum, type Model } from '@zenstackhq/language/ast';
import { DataModel, Enum, isDataField, type DataField, type Model } from '@zenstackhq/language/ast';
import colors from 'colors';
import fs from 'node:fs';
import path from 'node:path';
Expand All @@ -14,7 +14,7 @@ import {
} from './action-utils';
import { consolidateEnums, syncEnums, syncRelation, syncTable, type Relation } from './pull';
import { providers as pullProviders } from './pull/provider';
import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName, isDatabaseManagedAttribute } from './pull/utils';
import { getDatasource, getDbName, getRelationFieldsKey, getRelationFkName, getRelationName, isDatabaseManagedAttribute } from './pull/utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import { CliError } from '../cli-error';

Expand All @@ -35,6 +35,25 @@ export type PullOptions = {
indent: number;
};

function hasRelationFieldsArg(field: DataField) {
const relationAttr = field.attributes.find((a) => a.decl.ref?.name === '@relation');
return !!relationAttr?.args.some((a) => a.name === 'fields');
}

function getReferencedModelName(field: DataField) {
return field.type.reference?.ref ? getDbName(field.type.reference.ref) : undefined;
}

function matchesRelationNameFallback(field: DataField, relationName: string, candidate: DataField) {
const referencedModelName = getReferencedModelName(field);
return (
!!referencedModelName &&
getRelationName(candidate) === relationName &&
hasRelationFieldsArg(candidate) === hasRelationFieldsArg(field) &&
getReferencedModelName(candidate) === referencedModelName
);
}

/**
* CLI action for db related commands
*/
Expand Down Expand Up @@ -283,46 +302,52 @@ async function runPull(options: PullOptions) {
}

newDataModel.fields.forEach((f) => {
// Prioritized matching: exact db name > relation fields key > relation FK name > type reference
// Prioritized matching: exact db name > relation fields key > relation FK name > relation name > type reference
let originalFields = originalDataModel.fields.filter((d) => getDbName(d) === getDbName(f));

// If this is a back-reference relation field (has @relation but no `fields` arg), silently skip
const isRelationField =
f.$type === 'DataField' && !!(f as any).attributes?.some((a: any) => a?.decl?.ref?.name === '@relation');
if (originalFields.length === 0 && isRelationField && !getRelationFieldsKey(f as any)) {
return;
}

if (originalFields.length === 0) {
// Try matching by relation fields key (the `fields` attribute in @relation)
// This matches relation fields by their FK field references
const newFieldsKey = getRelationFieldsKey(f as any);
const newFieldsKey = isDataField(f) ? getRelationFieldsKey(f) : undefined;
if (newFieldsKey) {
originalFields = originalDataModel.fields.filter(
(d) => getRelationFieldsKey(d as any) === newFieldsKey,
(d) => isDataField(d) && getRelationFieldsKey(d) === newFieldsKey,
);
}
}

if (originalFields.length === 0) {
// Try matching by relation FK name (the `map` attribute in @relation)
originalFields = originalDataModel.fields.filter(
(d) =>
getRelationFkName(d as any) === getRelationFkName(f as any) &&
!!getRelationFkName(d as any) &&
!!getRelationFkName(f as any),
);
const newFkName = isDataField(f) ? getRelationFkName(f) : undefined;
if (newFkName) {
originalFields = originalDataModel.fields.filter(
(d) => isDataField(d) && getRelationFkName(d) === newFkName,
);
}
}

if (originalFields.length === 0) {
// Try matching by relation name (the `name` arg in @relation)
// This is essential for back-reference fields that only have a relation name
const newRelName = isDataField(f) ? getRelationName(f) : undefined;
if (newRelName) {
originalFields = originalDataModel.fields.filter(
(d) =>
isDataField(d) &&
isDataField(f) &&
matchesRelationNameFallback(f, newRelName, d),
);
}
}

if (originalFields.length === 0) {
// Try matching by type reference
// We need this because for relations that don't have @relation, we can only check if the original exists by the field type.
// Yes, in this case it can potentially result in multiple original fields, but we only want to ensure that at least one relation exists.
// In the future, we might implement some logic to detect how many of these types of relations we need and add/remove fields based on this.
originalFields = originalDataModel.fields.filter(
(d) =>
f.$type === 'DataField' &&
d.$type === 'DataField' &&
isDataField(f) &&
isDataField(d) &&
f.type.reference?.ref &&
d.type.reference?.ref &&
getDbName(f.type.reference.ref) === getDbName(d.type.reference.ref),
Expand All @@ -332,7 +357,7 @@ async function runPull(options: PullOptions) {
if (originalFields.length > 1) {
// If this is a back-reference relation field (no `fields` attribute),
// silently skip when there are multiple potential matches
const isBackReferenceField = !getRelationFieldsKey(f as any);
const isBackReferenceField = isDataField(f) && !getRelationFieldsKey(f);
if (!isBackReferenceField) {
console.warn(
colors.yellow(
Expand Down Expand Up @@ -499,31 +524,43 @@ async function runPull(options: PullOptions) {
});
originalDataModel.fields
.filter((f) => {
// Prioritized matching: exact db name > relation fields key > relation FK name > type reference
// Prioritized matching: exact db name > relation fields key > relation FK name > relation name > type reference
const matchByDbName = newDataModel.fields.find((d) => getDbName(d) === getDbName(f));
if (matchByDbName) return false;

// Try matching by relation fields key (the `fields` attribute in @relation)
const originalFieldsKey = getRelationFieldsKey(f as any);
const originalFieldsKey = isDataField(f) ? getRelationFieldsKey(f) : undefined;
if (originalFieldsKey) {
const matchByFieldsKey = newDataModel.fields.find(
(d) => getRelationFieldsKey(d as any) === originalFieldsKey,
(d) => isDataField(d) && getRelationFieldsKey(d) === originalFieldsKey,
);
if (matchByFieldsKey) return false;
}

const matchByFkName = newDataModel.fields.find(
(d) =>
getRelationFkName(d as any) === getRelationFkName(f as any) &&
!!getRelationFkName(d as any) &&
!!getRelationFkName(f as any),
);
if (matchByFkName) return false;
const originalFkName = isDataField(f) ? getRelationFkName(f) : undefined;
if (originalFkName) {
const matchByFkName = newDataModel.fields.find(
(d) => isDataField(d) && getRelationFkName(d) === originalFkName,
);
if (matchByFkName) return false;
}

// Try matching by relation name (for named back-reference fields)
const originalRelName = isDataField(f) ? getRelationName(f) : undefined;
if (originalRelName) {
const matchByRelName = newDataModel.fields.find(
(d) =>
isDataField(d) &&
isDataField(f) &&
matchesRelationNameFallback(f, originalRelName, d),
);
if (matchByRelName) return false;
}

const matchByTypeRef = newDataModel.fields.find(
(d) =>
f.$type === 'DataField' &&
d.$type === 'DataField' &&
isDataField(f) &&
isDataField(d) &&
f.type.reference?.ref &&
d.type.reference?.ref &&
getDbName(f.type.reference.ref) === getDbName(d.type.reference.ref),
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/actions/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ZenStackClient, type ClientContract } from '@zenstackhq/orm';
import { MysqlDialect } from '@zenstackhq/orm/dialects/mysql';
import { PostgresDialect } from '@zenstackhq/orm/dialects/postgres';
import { SqliteDialect } from '@zenstackhq/orm/dialects/sqlite';
import type { SchemaDef } from '@zenstackhq/orm/schema';
import { RPCApiHandler } from '@zenstackhq/server/api';
import { ZenStackMiddleware } from '@zenstackhq/server/express';
import type BetterSqlite3 from 'better-sqlite3';
Expand All @@ -24,7 +25,6 @@ import type { Pool as PgPoolType } from 'pg';
import { CliError } from '../cli-error';
import { getVersion } from '../utils/version-utils';
import { getOutputPath, getSchemaFile, loadSchemaDocument } from './action-utils';
import type { SchemaDef } from '@zenstackhq/orm/schema';

type Options = {
output?: string;
Expand Down Expand Up @@ -198,7 +198,7 @@ async function createDialect(provider: string, databaseUrl: string, outputPath:
}
}

export function createProxyApp(client: ClientContract<any, any>, schema: any): express.Application {
export function createProxyApp(client: ClientContract<SchemaDef>, schema: SchemaDef): express.Application {
const app = express();
app.use(cors());
app.use(express.json({ limit: '5mb' }));
Expand All @@ -219,7 +219,7 @@ export function createProxyApp(client: ClientContract<any, any>, schema: any): e
return app;
}

function startServer(client: ClientContract<any, any>, schema: any, options: Options) {
function startServer(client: ClientContract<SchemaDef>, schema: any, options: Options) {
const app = createProxyApp(client, schema);

const server = app.listen(options.port, () => {
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/actions/pull/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
type StringLiteral,
} from '@zenstackhq/language/ast';
import type { AstFactory, ExpressionBuilder } from '@zenstackhq/language/factory';
import { getLiteralArray, getStringLiteral } from '@zenstackhq/language/utils';
import { getAttributeArgLiteral, getLiteralArray, getStringLiteral } from '@zenstackhq/language/utils';
import type { DataSourceProviderType } from '@zenstackhq/schema';
import type { Reference } from 'langium';
import { CliError } from '../../cli-error';
Expand Down Expand Up @@ -122,6 +122,19 @@ export function getRelationFkName(decl: DataField): string | undefined {
return schemaAttrValue?.value;
}

/**
* Gets the relation name from the @relation attribute's `name` argument.
* e.g., @relation('myRelation', fields: [...], references: [...]) -> "myRelation"
* e.g., @relation(name: 'myRelation', fields: [...], references: [...]) -> "myRelation"
* e.g., @relation(fields: [...], references: [...]) -> undefined
* e.g., @relation('backRef') -> "backRef"
*/
export function getRelationName(decl: DataField): string | undefined {
const relationAttr = decl?.attributes?.find((a) => a.decl?.ref?.name === '@relation');
if (!relationAttr) return undefined;
return getAttributeArgLiteral(relationAttr, 'name');
}

/**
* Gets the FK field names from the @relation attribute's `fields` argument.
* Returns a sorted, comma-separated string of field names for comparison.
Expand Down
Loading
Loading