AMJUULYA
AMJUULYA — Монголын gig economy платформ. Ажил үүсгэх, ажиллах, үйлчилгээ авах, төлбөр хийх бүгд нэг апп-д.
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證製作者的介紹僅有上面的一句話。 更多詳情請閱讀儲存庫 README。 儲存庫 README ↗
正在載入完整 README。
檢視完整 README ↗One commerce demo, several deployments over one Cloudflare D1 database. The same shared packages are rendered by:
apps/opennext: Next.js 16.5 canary on Workers through @opennextjs/cloudflareapps/vinext: Cloudflare's vinext (the Next.js API surface on Vite)apps/next: plain Next.js 16.5 canary for Vercel, reading D1 over the D1 REST APIThe product is modeled on next16-commerce, with the caching ideas of next16-airline (one caching tier per read) and next16-mail (account-scoped cached queries, a private session, heavy bodies kept out of prefetches). It aims to exercise Cache Components as fully as possible: 'use cache', 'use cache: private', 'use cache: remote', cacheLife, cacheTag, updateTag in Server Functions, Partial Prefetching, ensureStatic, and the prefetch() / navigation() render-stage gates.
The code follows the Next.js App Architecture skill: queries in <domain>-queries.ts (server-only), Server Functions in <domain>-actions.ts ('use server'), tags in a pure <domain>-cache.ts, and components that receive ids and own their skeletons.
apps/
next/ Next.js on Vercel (D1 over the REST API)
opennext/ Next.js + @opennextjs/cloudflare (added later)
vinext/ vinext on Cloudflare Workers
packages/
db/ Drizzle schema, migrations, seed, D1 client, Miniflare test database
core/ Data layer: queries by caching tier, Server Functions, tags, session
ui/ Shared React components (Tailwind CSS v4, Ariakit, View Transitions)
tools/
vrt/ Cross-app visual regression test: opennext vs vinext, pixel by pixel
@realworld/ui ──▶ @realworld/core ──▶ @realworld/db ──▶ drizzle-orm / D1
│ │
└── next/link └── next/cache, next/headers, next/navigation (src/framework only)
Packages ship TypeScript source. Each one exposes typed entry points through package.json exports, and apps import them by workspace package name (@realworld/core/product/product-queries). There are no paths, baseUrl or import aliases anywhere.
| Tool | Notes |
|---|---|
| pnpm workspaces | Versions live once in the catalog: in pnpm-workspace.yaml. |
| Turborepo | lint, typecheck, test and build run per package. |
| TypeScript 7 | tsc is the native TypeScript 7 compiler (typescript-7 in the catalog). typescript-eslint and Next.js still load the JavaScript compiler API, so the typescript package name resolves to @typescript/typescript6, which ships that API (its binary is tsc6). Strict settings live in tsconfig.base.json. |
| ESLint (flat config) | typescript-eslint strict + stylistic type-checked, react-hooks (including the React Compiler rules), eslint-config-prettier. any is an error. |
| Prettier | With prettier-plugin-tailwindcss. |
| Vitest | Per package. Database tests run against a real D1 database in Miniflare (the workerd runtime). |
cf (Cloudflare CLI) | D1 migrations and remote queries. Wrangler is not used. |
pnpm install
pnpm lint # ESLint in every package
pnpm typecheck # tsc --noEmit (TypeScript 7) in every package
pnpm test # Vitest in every package
pnpm format # Prettier
Node.js 24 or later is required. Scripts in packages/db/scripts run directly with Node's built-in TypeScript type stripping.
Each read has its own caching tier and arrives at a different stage of a navigation. Tags are defined once per domain in <domain>-cache.ts and shared by the query (cacheTag) and the Server Functions (updateTag).
| Read | Function (@realworld/core/…) | How it is cached | Tags | Lifetime | When it arrives |
|---|---|---|---|---|---|
| Categories, category counts, a category | category/category-queries: getCategories, getCategoriesWithCount, getCategory | 'use cache' | categories (+ products for counts) | days | In the static shell |
| Featured products (home page offers) | product/product-queries: getFeaturedProducts | 'use cache: remote', tagged | featured-products, products | max | In the static shell |
| A page of the catalog (search, sort, category) | product/product-queries: getProductsPage | 'use cache: remote', keyed on normalized search | products | hours | With the link's prefetch (<Link prefetch>) |
| Product header and details | product/product-queries: getProduct, getProductsByIds | 'use cache' per product | products, product:<id> | hours | With the link's prefetch |
| Rating summary | product/product-queries: getReviewSummary | 'use cache' per product | reviews:<id> | hours | With the link's prefetch |
| Stock badge (in stock / low / sold out) | product/product-queries: getStockStatus | 'use cache: remote', short-lived | stock:<id> | minutes | With the link's prefetch |
| The session (account id, cart id) | user/user-queries: getSession | 'use cache: private' (browser only) | session |
How the lifetimes were chosen: reads whose every write goes through a Server Function that calls updateTag use max. Reads that also change outside the app (catalog edits, prices, orders reducing stock) get a shorter profile matched to how stale they may get. Product changes made outside the app can be pushed with revalidateTag('products', 'max') from a route handler or webhook.
The gates (prefetch(), navigation()) are awaited in the app's feature component right before it calls the query, never inside the cache scope:
import { navigation } from '@realworld/core/render-stages';
import { getReviews } from '@realworld/core/product/product-queries';
export async function Reviews({ productId }: { productId: number }) {
await navigation(); // keep review bodies out of every prefetch
const reviews = await getReviews(productId);
// …
}
Per-user data never reads cookies inside a shared cache scope: an uncached wrapper resolves the session ('use cache: private') and passes only the normalized account or cart id into the cached function, which stays unexported.
Every Server Function re-reads the session, validates its input with zod, returns a discriminated union ({ ok: true, … } | { ok: false, error }), and updates only the tags whose data it changed. The tests in packages/core/test/actions.test.ts assert the exact tags.
| Server Function | Module | Updates |
|---|---|---|
addToCart(productId, quantity?) | cart/cart-actions | cart:<cartId>; plus session only when it creates the cart cookie |
updateCartQuantity(productId, quantity) | cart/cart-actions | cart:<cartId> |
removeFromCart(productId) | cart/cart-actions | cart:<cartId> |
setFeaturedProduct(productId, featured) | product/product-actions | featured-products, product:<id> |
toggleSaveProduct(productId) | user/user-actions | saved:<accountId> (saved list, saved flag and recommendations) |
signIn(state, formData) / signOut() | user/user-actions | session |
updateProfile(state, formData) | user/user-actions | account:<accountId> |
Cart changes check the live stock (uncached) and never invalidate the catalog. Featuring a product does not touch catalog pages. Sign-in is a demo flow: an email from the seed (for example [email protected]) signs in without a password.
@realworld/db| Export | What it is |
|---|---|
@realworld/db | createDb(binding), schema, row types, Database, D1Binding |
@realworld/db/seed | buildSeedData(), seed(db), seedStatements(), DEMO_ACCOUNT_ID, DEMO_ACCOUNT_EMAIL |
@realworld/db/migrations | migrationsDir, readMigrations() |
@realworld/db/d1-http | createD1HttpBinding(): a D1Database over the Cloudflare D1 REST API (no binding needed) |
@realworld/db/testing | createTestDatabase(): an isolated, migrated D1 database in Miniflare, also served over the emulated D1 REST API (api) |
Tables: categories, products (with the featured flag), inventory, accounts, reviews, cart_items (keyed by an opaque cart id cookie), saved_products. Money is stored in cents, timestamps as integer milliseconds. The seed is deterministic (seeded PRNG, fixed timestamps): 7 categories, 28 products (4 featured, some sold out, some low in stock), 2 accounts and generated reviews.
pnpm db:generate # drizzle-kit generate after a schema change
D1_DATABASE_ID=<uuid> pnpm db:migrate:local # cf d1 migrations apply <uuid> --local --dir packages/db/migrations
D1_DATABASE_ID=<uuid> pnpm db:seed:local # write the seed into the same local state
pnpm -s --filter @realworld/db db:seed-sql > seed.sql # the seed as SQL
cf identifies databases by ID and targets the remote database by default, so the scripts run locally unless you pass --remote (pnpm --filter @realworld/db db:migrate:remote, db:seed:remote). cf d1 query has no local mode yet, so the local seed opens the state cf keeps (~/.config/cloudflare/state/v3/d1 by default, override with --persist-to) through Miniflare; the remote seed goes through cf d1 query --sql.
@realworld/core| Export | What it is |
|---|---|
@realworld/core | Client-safe: tags, domain types, normalizeProductSearch, formatPrice, stockStatus, … |
@realworld/core/database | configureDatabase(resolver), getDb() |
@realworld/core/render-stages | prefetch(), navigation(); no-op fallbacks under the vinext export condition |
@realworld/core/<domain>/<domain>-queries | Server-only queries (category, product, cart, user) |
@realworld/core/<domain>/<domain>-actions | Server Functions (product, cart, user) |
@realworld/core/<domain>/<domain>-cache | Tag helpers |
@realworld/core/<domain>/<domain>-data | Framework-free D1 reads and writes, each taking a Database |
Each domain is split into a framework-free data layer (*-data.ts, tested against Miniflare D1) and a thin Next-facing layer (*-queries.ts with cache directives, *-actions.ts with updateTag). All Next.js imports go through src/framework/next.ts (next/cache, next/headers, next/navigation), which vinext shims; the only APIs vinext lacks today, prefetch() and navigation(), are swapped by an export condition.
@realworld/uiShared components, one per kebab-case file, imported as @realworld/ui/<file>: button, card, skeleton, price, star-rating, stock-badge, product-image, product-card (ProductCard, ProductGrid, ProductGridSkeleton), pagination, animated-suspense, and the client leaves quantity-stepper (useOptimistic action prop), modal (native <dialog>) and dropdown-menu (Ariakit menu). Components are pure and free of manual memoization, so the React Compiler can optimize them. Skeletons sit next to their components and match their size.
Both apps render the same store UI from this package, so they look identical (checked by pnpm vrt, below):
store-shell (StoreShell with per-session account and cart slots, MarketingShell), site-header, site-footer (names the runtime), cart-link (CartIconLink).catalog-layout, category-nav, product-search-form (a plain GET form), sort-links, product-results.product-page-layout (slots the app fills, each inside the app's own <Suspense> or <AnimatedSuspense>), product-header, product-details, live-stock, review-list, save-product (SignInToSaveLink), and the client leaves back-link (History API back within the app), add-to-cart-button and save-product-button. The buttons take the Server Function as an action prop (action={addToCart}), like quantity-stepper.Apps keep only thin feature components: they await the render-stage gates, call the queries and pass the data to these views.
Design tokens use light-dark() with color-scheme: light dark. Apps load them after Tailwind:
@import 'tailwindcss';
@import '@realworld/ui/styles.css';
Also add <meta name="color-scheme" content="light dark"> to the root layout's <head>.
Both apps declare the same binding, DB, in cloudflare.config.ts:
import { bindings, defineConfig } from 'cf/config';
export default defineConfig({
name: 'realworld-opennext', // or realworld-vinext
env: {
DB: bindings.d1({ id: '<d1-database-uuid>', name: 'realworld' }),
},
});
Use the same database ID for D1_DATABASE_ID when running the migration and seed scripts.
@realworld/core never reads the binding itself. Each app registers how to get it, once per server runtime, in a module that every server entry imports (the root layout and instrumentation.ts):
// apps/opennext/lib/database.ts
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { configureDatabase } from '@realworld/core/database';
configureDatabase(async () => (await getCloudflareContext({ async: true })).env.DB);
// apps/vinext/lib/database.ts
import { env } from 'cloudflare:workers';
import { configureDatabase } from '@realworld/core/database';
configureDatabase(() => env.DB);
The resolver runs on every getDb() call; the Drizzle client is memoized per binding object, so this is cheap. Until an app configures it, getDb() throws a DatabaseNotConfiguredError that points back here.
Runtimes without a D1 binding (Vercel, apps/next) register an adapter over the D1 REST API instead. It implements the same D1Database interface, so @realworld/core and Drizzle do not change:
// apps/next/lib/database.ts
import { configureDatabase } from '@realworld/core/database';
import type { D1Binding } from '@realworld/db';
import { createD1HttpBinding } from '@realworld/db/d1-http';
import { d1HttpOptionsFromEnv } from './d1-env.ts';
let binding: D1Binding | undefined;
configureDatabase(() => (binding ??= createD1HttpBinding(d1HttpOptionsFromEnv(process.env))));
CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_D1_DATABASE_ID and CLOUDFLARE_D1_API_TOKEN are server-only. Locally, without a token, apps/next runs against a seeded Miniflare D1 behind an emulated REST API. See apps/next/README.md for Vercel setup and every difference from apps/vinext.
apps/opennext, apps/next)// next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
reactCompiler: true,
transpilePackages: ['@realworld/core', '@realworld/ui', '@realworld/db'],
};
transpilePackages makes Next compile the packages' TypeScript and their 'use cache' / 'use server' directives. Routes whose output must stay static can assert it with export const ensureStatic = 'navigation' (or a less strict level); see node_modules/next/dist/docs/01-app/02-guides/keeping-pages-static.md. 'use cache: remote' needs a remote cache handler; configure the OpenNext Cloudflare cache (for example KV) for it.
apps/vinext)Add the vinext export condition so @realworld/core/render-stages resolves to its fallback. Top-level resolve.conditions only reaches Vite's client environment, so set it for the server environments too. Also resolve the packages that vinext's "use cache" / "use server" transforms inject into packages/* source from the app:
// vite.config.ts
import { defaultClientConditions, defaultServerConditions, defineConfig } from 'vite';
const serverResolve = { conditions: ['vinext', ...defaultServerConditions] };
export default defineConfig({
resolve: {
conditions: ['vinext', ...defaultClientConditions],
dedupe: ['@vitejs/plugin-rsc', 'react-server-dom-webpack', 'vinext'],
},
environments: { rsc: { resolve: serverResolve }, ssr: { resolve: serverResolve } },
// …
});
vinext implements 'use cache' (including the remote and private variants), cacheLife, cacheTag, updateTag, cookies() and next/link. It does not implement Partial Prefetching render stages yet, so prefetch() and navigation() resolve immediately there; the reads behind them stay cached. See apps/vinext/README.md for the feature-by-feature results on Workers.
2026-10-28 時点で vinext(1.3.1)では使えなかった機能(詳細は apps/vinext/README.md):
prefetch() / navigation(): export condition で何もしない関数に置き換えているensureStatic と instant のセグメント設定: 何も言わずに無視される@next/playwright の instant(): 遷移を止める仕組みがない'use cache: private': リクエスト単位のキャッシュのみ<ViewTransition>: SSR エラーになるため、その箇所は通常の <Suspense> を使っているapps/opennext and apps/vinext deploy to production on every push to main through Cloudflare Workers Builds (Git integration), each as its own Worker with its own root directory, build command, deploy command and watch paths. D1 migrations stay manual. The exact dashboard settings are in the "Workers Builds(CD)" section of apps/opennext/README.md and apps/vinext/README.md.
pnpm test
@realworld/db: migrations apply to a fresh Miniflare D1 database; the seed is deterministic, idempotent, and its SQL form recreates the same data. The D1 REST adapter runs against the same Miniflare database through an emulated /raw endpoint and must return exactly what the Workers binding returns (rows, meta, batches, errors, parameter binding, Drizzle joins).@realworld/core: every data function runs against a seeded Miniflare D1 database. Queries and Server Functions run with in-memory stand-ins for next/cache, next/headers and next/navigation, and the tests assert each query's tags and lifetime, each query's cache directive (read from source, matching the table above), and the exact tags each Server Function updates.@realworld/ui: components render under happy-dom with Testing Library.tools/vrt)pnpm vrt # build both apps, seed their local D1, serve, capture, compare
pnpm vrt --skip-build # reuse the existing builds
pnpm vrt --only product-7 # only targets whose id contains the text
pnpm vrt --opennext-url http://localhost:8799 --vinext-url http://localhost:4173 # running servers
Builds apps/opennext (served by wrangler dev on port 8790) and apps/vinext (vite preview on 4174) against their seeded local D1 state; nothing remote is created or deployed. Each route in tools/vrt/src/config.ts (ROUTES) is captured at desktop (1280×800) and mobile (390×844) as a full-page screenshot once every streamed Suspense boundary is revealed and fonts are loaded, with animations, transitions and View Transitions disabled. Request-time values ([data-testid="live-stock"]) and the runtime name in the footer are masked. The two screenshots are compared with pixelmatch; a target fails above 0.1% differing pixels. The report (tools/vrt/report/index.html, side by side with diff images) is gitignored. Add a route by adding one line to ROUTES.
stale: 300 |
| Per session, before the cached reads it keys |
| Signed-in account | user/user-queries: getCurrentAccount, requireAccount | 'use cache' per account id, from the private session | account:<id> | max | Per session |
| Your cart and cart count | cart/cart-queries: getCart, getCartCount | 'use cache' per cart id, after prefetch() | cart:<cartId> (+ products) | max | With a per-link prefetch |
| Your saved products, "is saved" | user/user-queries: getSavedProducts, isProductSaved | 'use cache' per account, after prefetch() | saved:<accountId> (+ products) | max | With a per-link prefetch |
| Reviews (full bodies) | product/product-queries: getReviews | 'use cache' per product, after navigation() | reviews:<id> | hours | On the navigation |
| Recommendations | user/user-queries: getRecommendations | 'use cache' per account, after navigation() | saved:<accountId>, featured-products, products | hours | On the navigation |
| Exact stock ("Only 3 left") | product/product-queries: getLiveStock | Uncached | – | – | On every request, in its own <Suspense> |