From 75d9c318ce9831446a8ca480269503dc1063bb70 Mon Sep 17 00:00:00 2001 From: Amir Reza Dalir Date: Wed, 11 Feb 2026 09:32:25 +0330 Subject: [PATCH 1/2] docs: revamp README with before/after comparison and badges --- README.md | 146 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 115 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e593003..a08c52c 100644 --- a/README.md +++ b/README.md @@ -2,40 +2,66 @@ > Factory-driven state management for Nuxt powered by [Harlem](https://harlemjs.com/) +![Version](https://img.shields.io/badge/version-5.3.0-42b883) +![License](https://img.shields.io/badge/license-MIT-blue) + Define your data **shape** once with Zod — get typed **models**, computed **views**, and async **actions** with a single `createStore` call. -- **Schema-first** — Define your data shape once, get TypeScript types and validation automatically -- **Reactive state** — Single items and collections with built-in mutations -- **Computed views** — Derived read-only state that updates when models change -- **API integration** — Declarative HTTP actions that fetch and commit data in one step -- **Status tracking** — Every action exposes loading, error, and status reactively -- **Concurrency control** — Block, skip, cancel, or allow parallel calls per action -- **Vue composables** — Reactive helpers for actions, models, and views in components -- **SSR ready** — Server-side rendering with automatic state hydration +Built on top of [Harlem](https://harlemjs.com/), a powerful and extensible state management library for Vue 3. -## Install +--- -```bash -npm install @diphyx/harlemify -``` +## The Problem + +Every Nuxt app has the same boilerplate for every API resource: ```typescript -// nuxt.config.ts -export default defineNuxtConfig({ - modules: ["@diphyx/harlemify"], -}); +// Without Harlemify — this gets written for EVERY resource + +// 1. Define types manually +interface User { + id: number; + name: string; + email: string; +} + +// 2. Define state +const users = ref([]); +const currentUser = ref(null); +const loading = ref(false); +const error = ref(null); + +// 3. Write fetch logic +async function fetchUsers() { + loading.value = true; + error.value = null; + try { + users.value = await $fetch("/api/users"); + } catch (e) { + error.value = e as Error; + } finally { + loading.value = false; + } +} + +// 4. Repeat for create, update, delete... +// 5. Repeat for every resource in your app... ``` -## Usage +## The Solution + +With Harlemify, define a data shape once and get everything else for free: ```typescript -const userShape = shape((factory) => ({ - id: factory.number().meta({ - identifier: true, - }), - name: factory.string(), - email: factory.email(), -})); +const userShape = shape((factory) => { + return { + id: factory.number().meta({ + identifier: true, + }), + name: factory.string(), + email: factory.email(), + }; +}); export const userStore = createStore({ name: "users", @@ -57,35 +83,93 @@ export const userStore = createStore({ { url: "/users", }, + { model: "list", mode: ModelManyMode.SET }, + ), + get: api.get( { - model: "list", - mode: ModelManyMode.SET, + url(view) { + return `/users/${view.user.value?.id}`; + }, }, + { model: "current", mode: ModelOneMode.SET }, + ), + create: api.post( + { + url: "/users", + }, + { model: "list", mode: ModelManyMode.ADD }, + ), + delete: api.delete( + { + url(view) { + return `/users/${view.user.value?.id}`; + }, + }, + { model: "list", mode: ModelManyMode.REMOVE }, ), }; }, }); ``` +Use it in any component with built-in composables: + ```vue ``` +Every action automatically tracks `loading`, `error`, and `status`. No manual ref management. + +## Features + +- **Schema-first** — Define your data shape once with Zod, get TypeScript types and validation automatically +- **Reactive models** — Single items (`one`) and collections (`many`) with built-in mutations: set, patch, add, remove, reset +- **Computed views** — Derived read-only state that updates when models change, with merge and clone support +- **Declarative API actions** — HTTP actions (GET, POST, PATCH, DELETE) that fetch and commit data in one step +- **Status tracking** — Every action exposes `loading`, `error`, and `status` reactively — no boilerplate +- **Concurrency control** — Block, skip, cancel, or allow parallel calls per action +- **Vue composables** — `useStoreAction`, `useStoreModel`, `useStoreView` for clean component integration +- **SSR ready** — Server-side rendering with automatic state hydration +- **Handler actions** — Custom async logic with full model/view access and typed payloads +- **Record collections** — Keyed collections (`many` with `RECORD` kind) for grouped data + +## Install + +```bash +npm install @diphyx/harlemify +``` + +```typescript +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ["@diphyx/harlemify"], +}); +``` + +That's it. No plugins, no providers, no setup functions. + ## Documentation +Full docs with guides, API reference, and examples: + [https://diphyx.github.io/harlemify/](https://diphyx.github.io/harlemify/) +## Contributing + +Contributions are welcome! Please open an issue first to discuss what you'd like to change. + ## License -MIT +[MIT](LICENSE) From 6f59249205e49685a15d3058c0e897a8778862aa Mon Sep 17 00:00:00 2001 From: Amir Reza Dalir Date: Sun, 15 Feb 2026 14:48:19 +0330 Subject: [PATCH 2/2] refactor: remove proxy option from useStoreView Remove the proxy layer from useStoreView, returning ComputedRef directly. This simplifies the API, eliminates vue-tsc type resolution issues (#34), and removes unnecessary boilerplate (#35). - Remove UseStoreViewData, UseStoreViewProxy, UseStoreViewOptions types - Remove toReactiveProxy utility and ReferenceProxy type - Replace with single UseStoreView type returning ComputedRef - Update playground, tests, e2e specs, and docs Closes #34, closes #35 --- docs/composables/README.md | 12 +----- docs/composables/use-store-view.md | 65 ++++-------------------------- e2e/composables.spec.ts | 21 +++------- playground/pages/composables.vue | 45 +++++++-------------- src/runtime/composables/view.ts | 45 +++------------------ src/runtime/core/utils/base.ts | 60 --------------------------- src/runtime/index.ts | 8 +--- test/composable.test.ts | 53 ++---------------------- 8 files changed, 38 insertions(+), 271 deletions(-) diff --git a/docs/composables/README.md b/docs/composables/README.md index 069de79..ed5a673 100644 --- a/docs/composables/README.md +++ b/docs/composables/README.md @@ -21,21 +21,13 @@ const { set, add, remove } = useStoreModel(store, "list"); ## [useStoreView](use-store-view.md) -Reactive view data with proxy access and change tracking. +Reactive view data as a `ComputedRef` with change tracking. ```typescript const { data, track } = useStoreView(store, "user"); -data.value; // User -data.name; // Proxy access without .value -``` - -Pass `proxy: false` to get a standard `ComputedRef` instead of the proxy: - -```typescript -const { data } = useStoreView(store, "user", { proxy: false }); - data.value; // User — standard ComputedRef +data.value.name; // string ``` ## [useStoreCompose](use-store-compose.md) diff --git a/docs/composables/use-store-view.md b/docs/composables/use-store-view.md index 723aabb..2407be0 100644 --- a/docs/composables/use-store-view.md +++ b/docs/composables/use-store-view.md @@ -1,33 +1,18 @@ # useStoreView -Returns reactive view data with proxy access and a `track` method for watching changes. +Returns reactive view data as a `ComputedRef` with a `track` method for watching changes. ## Basic Usage ```typescript const { data, track } = useStoreView(userStore, "user"); -data.value; // User — standard ref access -data.name; // string — proxy access without .value -data.email; // string — proxy access without .value +data.value; // User — standard ComputedRef access +data.value.name; // string +data.value.email; // string ``` -## Data Proxy - -The `data` object is a proxy that supports both `.value` for the full ref value and direct property access: - -```typescript -const { data } = useStoreView(userStore, "user"); - -// Standard access -data.value; // User - -// Proxy access — reads from the current .value -data.name; // equivalent to data.value.name -data.email; // equivalent to data.value.email -``` - -This is useful in templates where you want to avoid repeated `.value` checks: +In templates, Vue auto-unwraps the `ComputedRef`: ```vue ``` -## Without Proxy - -Pass `proxy: false` to get a standard Vue `ComputedRef` instead: - -```typescript -const { data } = useStoreView(userStore, "user", { proxy: false }); - -data.value; // User — standard ComputedRef -data.value.name; // access via .value in script -``` - -In templates, Vue auto-unwraps the `ComputedRef`, so `.value` is not needed: - -```vue - -``` - ## Track Watch for view changes with an optional stop handle: @@ -108,32 +74,15 @@ onMounted(() => {

{{ userData.name }}

{{ userData.email }}

    -
  • {{ user.name }}
  • +
  • {{ user.name }}
``` -## Options - -| Option | Type | Default | Description | -| ------- | --------- | ------- | ----------------------------------------------------------------------- | -| `proxy` | `boolean` | `true` | When `true`, returns a proxy. When `false`, returns a raw `ComputedRef` | - ## Return Type -### With Proxy (default) - -```typescript -type UseStoreViewProxy = { - data: { value: T } & { [K in keyof T]: T[K] }; - track: (handler: (value: T) => void, options?: UseStoreViewTrackOptions) => WatchStopHandle; -}; -``` - -### Without Proxy - ```typescript -type UseStoreViewComputed = { +type UseStoreView = { data: ComputedRef; track: (handler: (value: T) => void, options?: UseStoreViewTrackOptions) => WatchStopHandle; }; diff --git a/e2e/composables.spec.ts b/e2e/composables.spec.ts index 2c3d2c3..1aaa7e2 100644 --- a/e2e/composables.spec.ts +++ b/e2e/composables.spec.ts @@ -160,19 +160,19 @@ test.describe("composables page", () => { // useStoreView - test("view non-destructured: data proxy shows shape defaults when no selection", async ({ page }) => { + test("view data shows shape defaults when no selection", async ({ page }) => { await expect(page.getByTestId("view-data-value")).toContainText('"id":0'); await expect(page.getByTestId("view-data-title")).toHaveText(""); await expect(page.getByTestId("view-data-done")).toHaveText("false"); }); - test("view non-destructured: data proxy reflects selection", async ({ page }) => { + test("view data reflects selection", async ({ page }) => { await page.getByTestId("todo-1").getByTestId("select-todo").click(); await expect(page.getByTestId("view-data-title")).toHaveText("Buy groceries"); await expect(page.getByTestId("view-data-done")).toHaveText("false"); }); - test("view non-destructured: proxy access matches .value access", async ({ page }) => { + test("view data title and done match .value", async ({ page }) => { await page.getByTestId("todo-1").getByTestId("select-todo").click(); const json = await page.getByTestId("view-data-value").textContent(); const parsed = JSON.parse(json!); @@ -180,7 +180,7 @@ test.describe("composables page", () => { await expect(page.getByTestId("view-data-done")).toHaveText(String(parsed.done)); }); - test("view non-destructured: clear selection button uses proxy", async ({ page }) => { + test("view clear selection resets data", async ({ page }) => { await page.getByTestId("todo-1").getByTestId("select-todo").click(); await expect(page.getByTestId("clear-selection")).toBeVisible(); await page.getByTestId("clear-selection").click(); @@ -188,7 +188,7 @@ test.describe("composables page", () => { await expect(page.getByTestId("clear-selection")).not.toBeVisible(); }); - test("view non-destructured: selected highlight uses proxy", async ({ page }) => { + test("view selected highlight", async ({ page }) => { await page.getByTestId("todo-2").getByTestId("select-todo").click(); await expect(page.getByTestId("todo-2")).toHaveClass(/selected/); await expect(page.getByTestId("todo-1")).not.toHaveClass(/selected/); @@ -197,17 +197,6 @@ test.describe("composables page", () => { await expect(page.getByTestId("todo-2")).not.toHaveClass(/selected/); }); - test("view without proxy: shows shape defaults when no selection", async ({ page }) => { - await expect(page.getByTestId("view-computed-value")).toContainText('"title":""'); - await expect(page.getByTestId("view-computed-title")).toHaveText(""); - }); - - test("view without proxy: reflects selection via .value", async ({ page }) => { - await page.getByTestId("todo-1").getByTestId("select-todo").click(); - await expect(page.getByTestId("view-computed-title")).toHaveText("Buy groceries"); - await expect(page.getByTestId("view-computed-value")).toContainText("Buy groceries"); - }); - test("view destructured: pending data shows incomplete todos", async ({ page }) => { await expect(page.getByTestId("view-pending")).toContainText("Buy groceries"); await expect(page.getByTestId("view-pending")).toContainText("Deploy app"); diff --git a/playground/pages/composables.vue b/playground/pages/composables.vue index 3173ccd..4470ad4 100644 --- a/playground/pages/composables.vue +++ b/playground/pages/composables.vue @@ -14,10 +14,9 @@ const debouncedModel = useStoreModel(todoStore, "current", { debounce: 500 }); const throttledModel = useStoreModel(todoStore, "current", { throttle: 500 }); const modelLog = ref([]); -const { data: pendingData } = useStoreView(todoStore, "pending"); +const pendingView = useStoreView(todoStore, "pending"); const todoView = useStoreView(todoStore, "todo"); const todosView = useStoreView(todoStore, "todos"); -const computedView = useStoreView(todoStore, "todo", { proxy: false }); const trackLog = ref([]); onMounted(() => execute()); @@ -105,7 +104,12 @@ function clearTrackLog() {

{{ todosView.data.value.length }} todos

-
@@ -117,7 +121,7 @@ function clearTrackLog() { v-for="todo in todosView.data.value" :key="todo.id" class="list-item" - :class="{ selected: todoView.data.id === todo.id }" + :class="{ selected: todoView.data.value.id === todo.id }" :data-testid="`todo-${todo.id}`" >
@@ -250,7 +254,7 @@ function clearTrackLog() {

useStoreView

-

Data Proxy

+

Data

const todoView = useStoreView(store, "todo")

@@ -260,39 +264,22 @@ function clearTrackLog() { }}
- .data.title{{ todoView.data.title }} -
-
- .data.done{{ todoView.data.done }} -
-
-
- -
-

Without Proxy

-

useStoreView(store, "todo", { proxy: false })

-
-
- .data.value{{ - JSON.stringify(computedView.data.value) - }} + .data.value.title{{ todoView.data.value.title }}
- .data.value.title{{ computedView.data.value.title }} + .data.value.done{{ todoView.data.value.done }}

Pending View

-

const { data: pendingData } = useStoreView(store, "pending")

+

const pendingView = useStoreView(store, "pending")

{{
                     JSON.stringify(
-                        pendingData.value.map((t: Todo) => t.title),
+                        pendingView.data.value.map((t: Todo) => t.title),
                         null,
                         2,
                     )
@@ -330,8 +317,6 @@ function clearTrackLog() {
                 
  • useStoreModel many - { add, remove }
  • { debounce } / { throttle } - Rate-limited mutations
  • useStoreView(store, key) - { data, track }
  • -
  • { proxy: false } - Raw ComputedRef
  • -
  • Data proxy: data.title without .value
  • track(handler) - Watch view changes
  • diff --git a/src/runtime/composables/view.ts b/src/runtime/composables/view.ts index 3b73bb1..c9d15e1 100644 --- a/src/runtime/composables/view.ts +++ b/src/runtime/composables/view.ts @@ -1,7 +1,7 @@ import { type ComputedRef, type WatchStopHandle, watch } from "vue"; import type { ViewCall } from "../core/types/view"; -import { debounce, throttle, toReactiveProxy } from "../core/utils/base"; +import { debounce, throttle } from "../core/utils/base"; // Options @@ -12,61 +12,26 @@ export interface UseStoreViewTrackOptions { throttle?: number; } -export interface UseStoreViewOptions { - proxy?: boolean; -} - // Return -export type UseStoreViewData = { value: T } & (T extends Record - ? { [K in keyof T]: T[K] } - : Record); - -export type UseStoreViewProxy = { - data: UseStoreViewData; - track: (handler: (value: T) => void, options?: UseStoreViewTrackOptions) => WatchStopHandle; -}; - -export type UseStoreViewComputed = { +export type UseStoreView = { data: ComputedRef; track: (handler: (value: T) => void, options?: UseStoreViewTrackOptions) => WatchStopHandle; }; -// Helpers - -function resolveData(source: ComputedRef, proxy?: boolean): UseStoreViewData | ComputedRef { - if (proxy !== false) { - return toReactiveProxy(source) as UseStoreViewData; - } - - return source; -} - // Composable export function useStoreView< V extends Record, K extends keyof V & string, T = V[K] extends ComputedRef ? R : unknown, ->(store: { view: V }, key: K, options: UseStoreViewOptions & { proxy: false }): UseStoreViewComputed; - -export function useStoreView< - V extends Record, - K extends keyof V & string, - T = V[K] extends ComputedRef ? R : unknown, ->(store: { view: V }, key: K, options?: UseStoreViewOptions): UseStoreViewProxy; - -export function useStoreView< - V extends Record, - K extends keyof V & string, - T = V[K] extends ComputedRef ? R : unknown, ->(store: { view: V }, key: K, options?: UseStoreViewOptions): UseStoreViewProxy | UseStoreViewComputed { +>(store: { view: V }, key: K): UseStoreView { if (!store.view[key]) { throw new Error(`View "${key}" not found in store`); } const source = store.view[key]; - const data = resolveData(source, options?.proxy); + const data = source; function resolveCallback void>( callback: C, @@ -106,5 +71,5 @@ export function useStoreView< return { data, track, - } as UseStoreViewProxy | UseStoreViewComputed; + } as UseStoreView; } diff --git a/src/runtime/core/utils/base.ts b/src/runtime/core/utils/base.ts index 2a266c3..2fdf876 100644 --- a/src/runtime/core/utils/base.ts +++ b/src/runtime/core/utils/base.ts @@ -65,66 +65,6 @@ export function isEmptyRecord(record: Record | undefined): reco return false; } -// Proxy - -type ReferenceProxy = { value: T } & Record; - -export function toReactiveProxy(reference: { value: T }): ReferenceProxy { - function get(_target: unknown, prop: string | symbol): unknown { - if (prop === "value") { - return reference.value; - } - - if (!isObject(reference.value)) { - return undefined; - } - - return (reference.value as Record)[prop]; - } - - function has(_target: unknown, prop: string | symbol): boolean { - if (prop === "value") { - return true; - } - - if (!isObject(reference.value)) { - return false; - } - - return prop in (reference.value as object); - } - - function ownKeys(): (string | symbol)[] { - if (!isObject(reference.value)) { - return []; - } - - return Reflect.ownKeys(reference.value as object); - } - - function getOwnPropertyDescriptor(_target: unknown, prop: string | symbol): PropertyDescriptor | undefined { - if (!isObject(reference.value) || !(prop in (reference.value as object))) { - return undefined; - } - - return { - configurable: true, - enumerable: true, - value: (reference.value as Record)[prop], - }; - } - - return new Proxy( - {}, - { - get, - has, - ownKeys, - getOwnPropertyDescriptor, - }, - ) as ReferenceProxy; -} - // Timing export function debounce any>(callback: T, delay: number): T { diff --git a/src/runtime/index.ts b/src/runtime/index.ts index f681eac..a46e8b1 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -49,13 +49,7 @@ export type { UseStoreActionOptions, UseStoreAction } from "./composables/action export { useStoreModel } from "./composables/model"; export type { UseStoreModelOptions, UseStoreModel } from "./composables/model"; export { useStoreView } from "./composables/view"; -export type { - UseStoreViewOptions, - UseStoreViewProxy, - UseStoreViewComputed, - UseStoreViewData, - UseStoreViewTrackOptions, -} from "./composables/view"; +export type { UseStoreView, UseStoreViewTrackOptions } from "./composables/view"; // Config export type { RuntimeConfig } from "./config"; diff --git a/test/composable.test.ts b/test/composable.test.ts index 987bd4d..bf07d1b 100644 --- a/test/composable.test.ts +++ b/test/composable.test.ts @@ -415,7 +415,7 @@ describe("useStoreView", () => { expect(() => useStoreView(store, "nonexistent" as any)).toThrow('View "nonexistent" not found in store'); }); - describe("data proxy", () => { + describe("data", () => { it("data.value returns the view value", () => { const store = setup(); const { data } = useStoreView(store, "user"); @@ -432,59 +432,12 @@ describe("useStoreView", () => { expect(data.value).toEqual({ id: 1, name: "Alice" }); }); - it("data proxies property access", () => { - const store = setup(); - const { data } = useStoreView(store, "user"); - - store.model.user.set({ id: 1, name: "Alice" }); - - expect((data as any).name).toBe("Alice"); - expect((data as any).id).toBe(1); - }); - - it("data returns shape default for properties on initial state", () => { - const store = setup(); - const { data } = useStoreView(store, "user"); - - expect((data as any).name).toBe(""); - }); - - it("has operator works", () => { - const store = setup(); - const { data } = useStoreView(store, "user"); - - store.model.user.set({ id: 1, name: "Alice" }); - - expect("name" in data).toBe(true); - expect("value" in data).toBe(true); - expect("nonexistent" in data).toBe(false); - }); - }); - - describe("proxy: false", () => { it("data is a ComputedRef", () => { const store = setup(); - const { data } = useStoreView(store, "user", { proxy: false }); + const { data } = useStoreView(store, "user"); expect(data.value).toEqual({ id: 0, name: "" }); - }); - - it("data.value reflects model changes", () => { - const store = setup(); - const { data } = useStoreView(store, "user", { proxy: false }); - - store.model.user.set({ id: 1, name: "Alice" }); - - expect(data.value).toEqual({ id: 1, name: "Alice" }); - }); - - it("data does not proxy properties", () => { - const store = setup(); - const { data } = useStoreView(store, "user", { proxy: false }); - - store.model.user.set({ id: 1, name: "Alice" }); - - expect((data as any).name).toBeUndefined(); + expect((data as any).effect).toBeDefined(); }); });