Skip to content

Commit a3c2d22

Browse files
committed
Merge branch 'dev' into feat/updated-at-fields
2 parents 23a2dda + 5aaef62 commit a3c2d22

165 files changed

Lines changed: 15149 additions & 5129 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/build-test.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ permissions:
1515

1616
jobs:
1717
build-test:
18-
runs-on: buildjet-8vcpu-ubuntu-2204
18+
runs-on: ubuntu-latest
1919

2020
services:
2121
postgres:
@@ -59,7 +59,7 @@ jobs:
5959
version: 10.12.1
6060

6161
- name: Use Node.js ${{ matrix.node-version }}
62-
uses: buildjet/setup-node@v3
62+
uses: actions/setup-node@v4
6363
with:
6464
node-version: ${{ matrix.node-version }}
6565
cache: 'pnpm'
@@ -76,7 +76,7 @@ jobs:
7676
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
7777
7878
- name: Setup pnpm cache
79-
uses: buildjet/cache@v3
79+
uses: actions/cache@v4
8080
with:
8181
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
8282
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}

.github/workflows/claude-code-review.yml

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,7 @@ on:
1212

1313
jobs:
1414
claude-review:
15-
# Optional: Filter by PR author
16-
# if: |
17-
# github.event.pull_request.user.login == 'external-contributor' ||
18-
# github.event.pull_request.user.login == 'new-developer' ||
19-
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
20-
15+
if: ${{ github.event.pull_request.head.repo.fork == false }}
2116
runs-on: ubuntu-latest
2217
permissions:
2318
contents: read
@@ -33,6 +28,7 @@ jobs:
3328

3429
- name: Run Claude Code Review
3530
id: claude-review
31+
if: ${{ !contains(github.event.pull_request.title, '[WIP]') }}
3632
uses: anthropics/claude-code-action@beta
3733
with:
3834
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -71,7 +67,3 @@ jobs:
7167
# Optional: Add specific tools for running tests or linting
7268
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
7369

74-
# Optional: Skip review for certain conditions
75-
# if: |
76-
# !contains(github.event.pull_request.title, '[skip-review]') &&
77-
# !contains(github.event.pull_request.title, '[WIP]')

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
2020
### Testing
2121

2222
- E2E tests are in `tests/e2e/` directory
23+
- Regression tests for GitHub issues go in `tests/regression/test/` as `issue-{number}.test.ts`
2324

2425
### ZenStack CLI Commands
2526

@@ -74,6 +75,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
7475
- Kysely query builder as escape hatch instead of raw SQL
7576
- Schema-first approach with ZModel DSL extension of Prisma schema language
7677

78+
## Pull Requests
79+
80+
- Always target the `dev` branch (not `main`) when creating PRs
81+
7782
## Development Notes
7883

7984
- Always run `zenstack generate` after modifying ZModel schemas

package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "zenstack-v3",
3-
"version": "3.3.3",
3+
"version": "3.4.5",
44
"description": "ZenStack",
55
"packageManager": "pnpm@10.23.0",
66
"type": "module",
@@ -50,6 +50,11 @@
5050
"better-sqlite3",
5151
"esbuild",
5252
"vue-demi"
53-
]
53+
],
54+
"overrides": {
55+
"cookie@<0.7.0": ">=0.7.0",
56+
"lodash-es@>=4.0.0 <=4.17.22": ">=4.17.23",
57+
"lodash@>=4.0.0 <=4.17.22": ">=4.17.23"
58+
}
5459
}
5560
}

packages/auth-adapters/better-auth/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zenstackhq/better-auth",
3-
"version": "3.3.3",
3+
"version": "3.4.5",
44
"description": "ZenStack Better Auth Adapter. This adapter is modified from better-auth's Prisma adapter.",
55
"type": "module",
66
"scripts": {
@@ -46,9 +46,9 @@
4646
"better-auth": "^1.3.0"
4747
},
4848
"devDependencies": {
49-
"@better-auth/core": "1.4.17",
50-
"better-auth": "1.4.17",
51-
"@better-auth/cli": "1.4.17",
49+
"@better-auth/core": "1.4.19",
50+
"better-auth": "1.4.19",
51+
"@better-auth/cli": "1.4.19",
5252
"@types/tmp": "catalog:",
5353
"@zenstackhq/cli": "workspace:*",
5454
"@zenstackhq/eslint-config": "workspace:*",

packages/auth-adapters/better-auth/src/adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ export const zenstackAdapter = <Schema extends SchemaDef>(db: ClientContract<Sch
171171
const whereClause = convertWhereClause(model, where);
172172
return await modelDb.update({
173173
where: whereClause,
174-
data: update as UpdateInput<SchemaDef, GetModels<SchemaDef>>,
174+
data: update as UpdateInput<SchemaDef, GetModels<SchemaDef>, any>,
175175
});
176176
},
177177

packages/cli/package.json

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"publisher": "zenstack",
44
"displayName": "ZenStack CLI",
55
"description": "FullStack database toolkit with built-in access control and automatic API generation.",
6-
"version": "3.3.3",
6+
"version": "3.4.5",
77
"type": "module",
88
"author": {
99
"name": "ZenStack Team"
@@ -37,12 +37,11 @@
3737
},
3838
"dependencies": {
3939
"@zenstackhq/common-helpers": "workspace:*",
40-
"@zenstackhq/schema": "workspace:*",
4140
"@zenstackhq/language": "workspace:*",
4241
"@zenstackhq/orm": "workspace:*",
42+
"@zenstackhq/schema": "workspace:*",
4343
"@zenstackhq/sdk": "workspace:*",
4444
"@zenstackhq/server": "workspace:*",
45-
"better-sqlite3": "catalog:",
4645
"chokidar": "^5.0.0",
4746
"colors": "1.4.0",
4847
"commander": "^8.3.0",
@@ -53,13 +52,13 @@
5352
"jiti": "^2.6.1",
5453
"langium": "catalog:",
5554
"mixpanel": "^0.18.1",
56-
"mysql2": "catalog:",
5755
"ora": "^5.4.1",
5856
"package-manager-detector": "^1.3.0",
59-
"pg": "catalog:",
6057
"prisma": "catalog:",
6158
"semver": "^7.7.2",
62-
"ts-pattern": "catalog:"
59+
"terminal-link": "^5.0.0",
60+
"ts-pattern": "catalog:",
61+
"zod": "catalog:"
6362
},
6463
"devDependencies": {
6564
"@types/better-sqlite3": "catalog:",
@@ -74,7 +73,23 @@
7473
"@zenstackhq/vitest-config": "workspace:*",
7574
"tmp": "catalog:"
7675
},
76+
"peerDependencies": {
77+
"better-sqlite3": "catalog:",
78+
"mysql2": "catalog:",
79+
"pg": "catalog:"
80+
},
81+
"peerDependenciesMeta": {
82+
"pg": {
83+
"optional": true
84+
},
85+
"better-sqlite3": {
86+
"optional": true
87+
},
88+
"mysql2": {
89+
"optional": true
90+
}
91+
},
7792
"engines": {
7893
"node": ">=20"
7994
}
80-
}
95+
}

packages/cli/src/actions/action-utils.ts

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import { invariant } from '@zenstackhq/common-helpers';
12
import { type ZModelServices, loadDocument } from '@zenstackhq/language';
2-
import { type Model, isDataSource } from '@zenstackhq/language/ast';
3-
import { PrismaSchemaGenerator } from '@zenstackhq/sdk';
3+
import { type Model, type Plugin, isDataSource, type LiteralExpr } from '@zenstackhq/language/ast';
4+
import { type CliPlugin, PrismaSchemaGenerator } from '@zenstackhq/sdk';
45
import colors from 'colors';
6+
import { createJiti } from 'jiti';
57
import fs from 'node:fs';
68
import { createRequire } from 'node:module';
79
import path from 'node:path';
10+
import { pathToFileURL } from 'node:url';
11+
import terminalLink from 'terminal-link';
12+
import { z } from 'zod';
813
import { CliError } from '../cli-error';
914

1015
export function getSchemaFile(file?: string) {
@@ -216,3 +221,152 @@ export async function getZenStackPackages(
216221

217222
return result.filter((p) => !!p);
218223
}
224+
225+
export function getPluginProvider(plugin: Plugin) {
226+
const providerField = plugin.fields.find((f) => f.name === 'provider');
227+
invariant(providerField, `Plugin ${plugin.name} does not have a provider field`);
228+
const provider = (providerField.value as LiteralExpr).value as string;
229+
return provider;
230+
}
231+
232+
export async function loadPluginModule(provider: string, basePath: string) {
233+
if (provider.toLowerCase().endsWith('.zmodel')) {
234+
// provider is a zmodel file, no plugin code module to load
235+
return undefined;
236+
}
237+
238+
let moduleSpec = provider;
239+
if (moduleSpec.startsWith('.')) {
240+
// relative to schema's path
241+
moduleSpec = path.resolve(basePath, moduleSpec);
242+
}
243+
244+
const importAsEsm = async (spec: string) => {
245+
try {
246+
const result = (await import(spec)).default as CliPlugin;
247+
return result;
248+
} catch (err) {
249+
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
250+
}
251+
};
252+
253+
const jiti = createJiti(pathToFileURL(basePath).toString());
254+
const importAsTs = async (spec: string) => {
255+
try {
256+
const result = (await jiti.import(spec, { default: true })) as CliPlugin;
257+
return result;
258+
} catch (err) {
259+
throw new CliError(`Failed to load plugin module from ${spec}: ${(err as Error).message}`);
260+
}
261+
};
262+
263+
const esmSuffixes = ['.js', '.mjs'];
264+
const tsSuffixes = ['.ts', '.mts'];
265+
266+
if (fs.existsSync(moduleSpec) && fs.statSync(moduleSpec).isFile()) {
267+
// try provider as ESM file
268+
if (esmSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
269+
return await importAsEsm(pathToFileURL(moduleSpec).toString());
270+
}
271+
272+
// try provider as TS file
273+
if (tsSuffixes.some((suffix) => moduleSpec.endsWith(suffix))) {
274+
return await importAsTs(moduleSpec);
275+
}
276+
}
277+
278+
// try ESM index files in provider directory
279+
for (const suffix of esmSuffixes) {
280+
const indexPath = path.join(moduleSpec, `index${suffix}`);
281+
if (fs.existsSync(indexPath)) {
282+
return await importAsEsm(pathToFileURL(indexPath).toString());
283+
}
284+
}
285+
286+
// try TS index files in provider directory
287+
for (const suffix of tsSuffixes) {
288+
const indexPath = path.join(moduleSpec, `index${suffix}`);
289+
if (fs.existsSync(indexPath)) {
290+
return await importAsTs(indexPath);
291+
}
292+
}
293+
294+
// last resort, try to import as esm directly
295+
try {
296+
const mod = await import(moduleSpec);
297+
// plugin may not export a generator, return undefined in that case
298+
return mod.default as CliPlugin | undefined;
299+
} catch (err) {
300+
const errorCode = (err as NodeJS.ErrnoException)?.code;
301+
if (errorCode === 'ERR_MODULE_NOT_FOUND' || errorCode === 'MODULE_NOT_FOUND') {
302+
throw new CliError(`Cannot find plugin module "${provider}". Please make sure the package exists.`);
303+
}
304+
throw new CliError(`Failed to load plugin module "${provider}": ${(err as Error).message}`);
305+
}
306+
}
307+
308+
const FETCH_CLI_MAX_TIME = 1000;
309+
const CLI_CONFIG_ENDPOINT = 'https://zenstack.dev/config/cli-v3.json';
310+
311+
const usageTipsSchema = z.object({
312+
notifications: z.array(z.object({ title: z.string(), url: z.url().optional(), active: z.boolean() })),
313+
});
314+
315+
/**
316+
* Starts the usage tips fetch in the background. Returns a callback that, when invoked check if the fetch
317+
* is complete. If not complete, it will wait until the max time is reached. After that, if fetch is still
318+
* not complete, just return.
319+
*/
320+
export function startUsageTipsFetch() {
321+
let fetchedData: z.infer<typeof usageTipsSchema> | undefined = undefined;
322+
let fetchComplete = false;
323+
324+
const start = Date.now();
325+
const controller = new AbortController();
326+
327+
fetch(CLI_CONFIG_ENDPOINT, {
328+
headers: { accept: 'application/json' },
329+
signal: controller.signal,
330+
})
331+
.then(async (res) => {
332+
if (!res.ok) return;
333+
const data = await res.json();
334+
const parseResult = usageTipsSchema.safeParse(data);
335+
if (parseResult.success) {
336+
fetchedData = parseResult.data;
337+
}
338+
})
339+
.catch(() => {
340+
// noop
341+
})
342+
.finally(() => {
343+
fetchComplete = true;
344+
});
345+
346+
return async () => {
347+
const elapsed = Date.now() - start;
348+
349+
if (!fetchComplete && elapsed < FETCH_CLI_MAX_TIME) {
350+
// wait for the timeout
351+
await new Promise((resolve) => setTimeout(resolve, FETCH_CLI_MAX_TIME - elapsed));
352+
}
353+
354+
if (!fetchComplete) {
355+
controller.abort();
356+
return;
357+
}
358+
359+
if (!fetchedData) return;
360+
361+
const activeItems = fetchedData.notifications.filter((item) => item.active);
362+
// show a random active item
363+
if (activeItems.length > 0) {
364+
const item = activeItems[Math.floor(Math.random() * activeItems.length)]!;
365+
if (item.url) {
366+
console.log(terminalLink(item.title, item.url));
367+
} else {
368+
console.log(item.title);
369+
}
370+
}
371+
};
372+
}

packages/cli/src/actions/check.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { isPlugin, type Model } from '@zenstackhq/language/ast';
12
import colors from 'colors';
2-
import { getSchemaFile, loadSchemaDocument } from './action-utils';
3+
import path from 'node:path';
4+
import { getPluginProvider, getSchemaFile, loadPluginModule, loadSchemaDocument } from './action-utils';
35

46
type Options = {
57
schema?: string;
@@ -12,11 +14,22 @@ export async function run(options: Options) {
1214
const schemaFile = getSchemaFile(options.schema);
1315

1416
try {
15-
await loadSchemaDocument(schemaFile);
17+
const model = await loadSchemaDocument(schemaFile);
18+
await checkPluginResolution(schemaFile, model);
1619
console.log(colors.green('✓ Schema validation completed successfully.'));
1720
} catch (error) {
1821
console.error(colors.red('✗ Schema validation failed.'));
1922
// Re-throw to maintain CLI exit code behavior
2023
throw error;
2124
}
2225
}
26+
27+
async function checkPluginResolution(schemaFile: string, model: Model) {
28+
const plugins = model.declarations.filter(isPlugin);
29+
for (const plugin of plugins) {
30+
const provider = getPluginProvider(plugin);
31+
if (!provider.startsWith('@core/')) {
32+
await loadPluginModule(provider, path.dirname(schemaFile));
33+
}
34+
}
35+
}

0 commit comments

Comments
 (0)