@praxisjs/store 1.2.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/__tests__/plugins.test.d.ts +2 -0
- package/dist/__tests__/plugins.test.d.ts.map +1 -0
- package/dist/__tests__/plugins.test.js +379 -0
- package/dist/__tests__/plugins.test.js.map +1 -0
- package/dist/apply-plugins.d.ts +3 -0
- package/dist/apply-plugins.d.ts.map +1 -0
- package/dist/apply-plugins.js +66 -0
- package/dist/apply-plugins.js.map +1 -0
- package/dist/create-store.d.ts +6 -1
- package/dist/create-store.d.ts.map +1 -1
- package/dist/create-store.js +47 -4
- package/dist/create-store.js.map +1 -1
- package/dist/decorators.d.ts +5 -1
- package/dist/decorators.d.ts.map +1 -1
- package/dist/decorators.js +12 -2
- package/dist/decorators.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/plugin-registry.d.ts +5 -0
- package/dist/plugin-registry.d.ts.map +1 -0
- package/dist/plugin-registry.js +11 -0
- package/dist/plugin-registry.js.map +1 -0
- package/dist/plugin-types.d.ts +28 -0
- package/dist/plugin-types.d.ts.map +1 -0
- package/dist/plugin-types.js +2 -0
- package/dist/plugin-types.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/plugins.test.ts +445 -0
- package/src/apply-plugins.ts +77 -0
- package/src/create-store.ts +56 -4
- package/src/decorators.ts +23 -2
- package/src/index.ts +10 -1
- package/src/plugin-registry.ts +15 -0
- package/src/plugin-types.ts +31 -0
package/dist/decorators.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"decorators.d.ts","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"decorators.d.ts","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,YAAY,EAClB,MAAM,sBAAsB,CAAC;AAK9B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAQrD,qGAAqG;AACrG,qBAAa,aAAc,YAAW,YAAY;IAChD,WAAW,UAAS;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;CACzB;AAED,wBAAgB,QAAQ,CAAC,OAAO,GAAE,eAAoB,IAElD,aAAa,KAAK,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,aAAa,EACtD,UAAU,qBAAqB,KAC9B,IAAI,CAMR;AAED,wBAAgB,KAAK,CAAC,CAAC,SAAS,aAAa,EAAE,UAAU,EAAE,UAAU,CAAC,GAAG,CAAC,CAUzE;AAED,wBAAgB,KAAK,CAAC,gBAAgB,EAAE,UAAU,OAAO,GAYtC,CAAC,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,0BAA0B,CAAC,GAAG,CAAC,KAAK,IAAI,CACvF"}
|
package/dist/decorators.js
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
import { createFieldDecorator, } from "@praxisjs/decorators";
|
|
2
|
+
import { applyPluginsToStore } from "./apply-plugins.js";
|
|
3
|
+
import { getGlobalPlugins } from "./plugin-registry.js";
|
|
2
4
|
const storeRegistry = new Map();
|
|
5
|
+
const storePluginsMap = new WeakMap();
|
|
3
6
|
/** Base class for store classes. Extend this to enable `@State` and `@DeepState` on store fields. */
|
|
4
7
|
export class ReactiveStore {
|
|
5
8
|
constructor() {
|
|
6
9
|
this._stateDirty = false;
|
|
7
10
|
}
|
|
8
11
|
}
|
|
9
|
-
export function Storable() {
|
|
12
|
+
export function Storable(options = {}) {
|
|
10
13
|
return function (constructor, _context) {
|
|
11
14
|
storeRegistry.set(constructor, null);
|
|
15
|
+
if (options.plugins?.length) {
|
|
16
|
+
storePluginsMap.set(constructor, options.plugins);
|
|
17
|
+
}
|
|
12
18
|
};
|
|
13
19
|
}
|
|
14
20
|
export function store(StoreClass) {
|
|
15
21
|
if (!storeRegistry.has(StoreClass) || storeRegistry.get(StoreClass) === null) {
|
|
16
|
-
|
|
22
|
+
const instance = new StoreClass();
|
|
23
|
+
const perStore = storePluginsMap.get(StoreClass) ?? [];
|
|
24
|
+
const plugins = [...getGlobalPlugins(), ...perStore];
|
|
25
|
+
const wrapped = applyPluginsToStore(instance, StoreClass.name, plugins);
|
|
26
|
+
storeRegistry.set(StoreClass, wrapped);
|
|
17
27
|
}
|
|
18
28
|
return storeRegistry.get(StoreClass);
|
|
19
29
|
}
|
package/dist/decorators.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"decorators.js","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,oBAAoB,GAGrB,MAAM,sBAAsB,CAAC;AAE9B,MAAM,aAAa,GAAG,IAAI,GAAG,EAAgD,CAAC;
|
|
1
|
+
{"version":3,"file":"decorators.js","sourceRoot":"","sources":["../src/decorators.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,oBAAoB,GAGrB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAIxD,MAAM,aAAa,GAAG,IAAI,GAAG,EAAgD,CAAC;AAC9E,MAAM,eAAe,GAAG,IAAI,OAAO,EAGhC,CAAC;AAEJ,qGAAqG;AACrG,MAAM,OAAO,aAAa;IAA1B;QACE,gBAAW,GAAG,KAAK,CAAC;IACtB,CAAC;CAAA;AAMD,MAAM,UAAU,QAAQ,CAAC,UAA2B,EAAE;IACpD,OAAO,UACL,WAAsD,EACtD,QAA+B;QAE/B,aAAa,CAAC,GAAG,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;QACrC,IAAI,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,CAAC;YAC5B,eAAe,CAAC,GAAG,CAAC,WAAW,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;QACpD,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,KAAK,CAA0B,UAAuB;IACpE,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,aAAa,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,IAAI,EAAE,CAAC;QAC7E,MAAM,QAAQ,GAAG,IAAI,UAAU,EAAE,CAAC;QAClC,MAAM,QAAQ,GACZ,eAAe,CAAC,GAAG,CAAC,UAAuD,CAAC,IAAI,EAAE,CAAC;QACrF,MAAM,OAAO,GAAG,CAAC,GAAG,gBAAgB,EAAE,EAAE,GAAG,QAAQ,CAAC,CAAC;QACrD,MAAM,OAAO,GAAG,mBAAmB,CAAC,QAAQ,EAAE,UAAU,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QACxE,aAAa,CAAC,GAAG,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;IACzC,CAAC;IACD,OAAO,aAAa,CAAC,GAAG,CAAC,UAAU,CAAM,CAAC;AAC5C,CAAC;AAED,MAAM,UAAU,KAAK,CAAC,gBAAmC;IACvD,OAAO,oBAAoB,CAAC;QAC1B,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,aAAa;YAClC,OAAO;gBACL,UAAU,EAAE;oBACV,GAAG;wBACD,OAAO,KAAK,CAAC,gBAA2C,CAAC,CAAC;oBAC5D,CAAC;iBACF;aACF,CAAC;QACJ,CAAC;QACH,8DAA8D;KAC7D,CAAqF,CAAC;AACzF,CAAC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export { Storable, Store, store, ReactiveStore } from "./decorators";
|
|
1
|
+
export { Storable, Store, store, ReactiveStore } from "./decorators.js";
|
|
2
|
+
export type { StorableOptions } from "./decorators.js";
|
|
3
|
+
export { useStorePlugin, clearPlugins } from "./plugin-registry.js";
|
|
4
|
+
export type { StorePlugin, StoreMutation, StoreActionContext, StoreActionResult, StoreInitContext, } from "./plugin-types.js";
|
|
2
5
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACxE,YAAY,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACpE,YAAY,EACV,WAAW,EACX,aAAa,EACb,kBAAkB,EAClB,iBAAiB,EACjB,gBAAgB,GACjB,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAExE,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { StorePlugin } from "./plugin-types.js";
|
|
2
|
+
export declare function useStorePlugin(plugin: StorePlugin): void;
|
|
3
|
+
export declare function getGlobalPlugins(): StorePlugin[];
|
|
4
|
+
export declare function clearPlugins(): void;
|
|
5
|
+
//# sourceMappingURL=plugin-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin-registry.d.ts","sourceRoot":"","sources":["../src/plugin-registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAIrD,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CAExD;AAED,wBAAgB,gBAAgB,IAAI,WAAW,EAAE,CAEhD;AAED,wBAAgB,YAAY,IAAI,IAAI,CAEnC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const globalRegistry = [];
|
|
2
|
+
export function useStorePlugin(plugin) {
|
|
3
|
+
globalRegistry.push(plugin);
|
|
4
|
+
}
|
|
5
|
+
export function getGlobalPlugins() {
|
|
6
|
+
return globalRegistry.slice();
|
|
7
|
+
}
|
|
8
|
+
export function clearPlugins() {
|
|
9
|
+
globalRegistry.length = 0;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=plugin-registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin-registry.js","sourceRoot":"","sources":["../src/plugin-registry.ts"],"names":[],"mappings":"AAEA,MAAM,cAAc,GAAkB,EAAE,CAAC;AAEzC,MAAM,UAAU,cAAc,CAAC,MAAmB;IAChD,cAAc,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AAC9B,CAAC;AAED,MAAM,UAAU,gBAAgB;IAC9B,OAAO,cAAc,CAAC,KAAK,EAAE,CAAC;AAChC,CAAC;AAED,MAAM,UAAU,YAAY;IAC1B,cAAc,CAAC,MAAM,GAAG,CAAC,CAAC;AAC5B,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface StoreMutation {
|
|
2
|
+
key: string;
|
|
3
|
+
value: unknown;
|
|
4
|
+
prevValue: unknown;
|
|
5
|
+
storeName: string;
|
|
6
|
+
}
|
|
7
|
+
export interface StoreActionContext {
|
|
8
|
+
name: string;
|
|
9
|
+
args: unknown[];
|
|
10
|
+
storeName: string;
|
|
11
|
+
}
|
|
12
|
+
export interface StoreActionResult extends StoreActionContext {
|
|
13
|
+
result: unknown;
|
|
14
|
+
error?: unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface StoreInitContext {
|
|
17
|
+
store: Record<string, unknown>;
|
|
18
|
+
storeName: string;
|
|
19
|
+
extend(props: Record<string, unknown>): void;
|
|
20
|
+
}
|
|
21
|
+
export interface StorePlugin {
|
|
22
|
+
name: string;
|
|
23
|
+
onInit?(context: StoreInitContext): void;
|
|
24
|
+
onMutation?(mutation: StoreMutation): void;
|
|
25
|
+
onAction?(context: StoreActionContext): void;
|
|
26
|
+
onActionDone?(result: StoreActionResult): void;
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=plugin-types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin-types.d.ts","sourceRoot":"","sources":["../src/plugin-types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,iBAAkB,SAAQ,kBAAkB;IAC3D,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CAC9C;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACzC,UAAU,CAAC,CAAC,QAAQ,EAAE,aAAa,GAAG,IAAI,CAAC;IAC3C,QAAQ,CAAC,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAAC;IAC7C,YAAY,CAAC,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAC;CAChD"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin-types.js","sourceRoot":"","sources":["../src/plugin-types.ts"],"names":[],"mappings":""}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@praxisjs/store",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"typescript": "^5.9.3"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@praxisjs/core": "1.
|
|
18
|
-
"@praxisjs/decorators": "1.
|
|
17
|
+
"@praxisjs/core": "1.8.0",
|
|
18
|
+
"@praxisjs/decorators": "1.3.0"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "tsc",
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { clearPlugins, useStorePlugin } from "../plugin-registry";
|
|
4
|
+
import { createStore } from "../create-store";
|
|
5
|
+
import { Storable, store, ReactiveStore } from "../decorators";
|
|
6
|
+
import type { StorePlugin, StoreMutation, StoreActionResult } from "../plugin-types";
|
|
7
|
+
|
|
8
|
+
// Isolate global plugins between tests
|
|
9
|
+
beforeEach(() => clearPlugins());
|
|
10
|
+
|
|
11
|
+
// ── createStore — per-store plugins ──────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("createStore — per-store plugins (options.plugins)", () => {
|
|
14
|
+
it("onInit receives storeName and store reference", () => {
|
|
15
|
+
const ctx: { storeName?: string; store?: unknown } = {};
|
|
16
|
+
const plugin: StorePlugin = {
|
|
17
|
+
name: "p",
|
|
18
|
+
onInit(context) { ctx.storeName = context.storeName; ctx.store = context.store; },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
createStore({ count: 0 }, { name: "counter", plugins: [plugin] });
|
|
22
|
+
expect(ctx.storeName).toBe("counter");
|
|
23
|
+
expect(ctx.store).toBeDefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("storeName defaults to 'anonymous' when not provided", () => {
|
|
27
|
+
let received = "";
|
|
28
|
+
createStore({ count: 0 }, { plugins: [{ name: "p", onInit({ storeName }) { received = storeName; } }] });
|
|
29
|
+
expect(received).toBe("anonymous");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("onMutation fires when a signal is set", () => {
|
|
33
|
+
const mutations: StoreMutation[] = [];
|
|
34
|
+
const s = createStore({ count: 0 }, {
|
|
35
|
+
name: "counter",
|
|
36
|
+
plugins: [{ name: "p", onMutation(m) { mutations.push(m); } }],
|
|
37
|
+
})();
|
|
38
|
+
|
|
39
|
+
(s as unknown as { count: number }).count = 5;
|
|
40
|
+
|
|
41
|
+
expect(mutations).toHaveLength(1);
|
|
42
|
+
expect(mutations[0]).toMatchObject({ key: "count", value: 5, prevValue: 0, storeName: "counter" });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("onMutation fires per key when $patch is called", () => {
|
|
46
|
+
const keys: string[] = [];
|
|
47
|
+
const s = createStore({ a: 1, b: 2 }, {
|
|
48
|
+
name: "s",
|
|
49
|
+
plugins: [{ name: "p", onMutation({ key }) { keys.push(key); } }],
|
|
50
|
+
})();
|
|
51
|
+
|
|
52
|
+
(s as unknown as { $patch: (p: Record<string, unknown>) => void }).$patch({ a: 10, b: 20 });
|
|
53
|
+
expect(keys).toContain("a");
|
|
54
|
+
expect(keys).toContain("b");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("onMutation does NOT fire for $reset", () => {
|
|
58
|
+
const mutations: string[] = [];
|
|
59
|
+
const s = createStore({ count: 0 }, {
|
|
60
|
+
plugins: [{ name: "p", onMutation({ key }) { mutations.push(key); } }],
|
|
61
|
+
})();
|
|
62
|
+
|
|
63
|
+
(s as unknown as { count: number }).count = 99;
|
|
64
|
+
mutations.length = 0;
|
|
65
|
+
(s as unknown as { $reset: () => void }).$reset();
|
|
66
|
+
expect(mutations).toHaveLength(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("onAction fires before the method; onActionDone fires after", () => {
|
|
70
|
+
const log: string[] = [];
|
|
71
|
+
const s = createStore({
|
|
72
|
+
count: 0,
|
|
73
|
+
increment() { log.push("run"); (this as unknown as { count: number }).count++; },
|
|
74
|
+
}, {
|
|
75
|
+
name: "s",
|
|
76
|
+
plugins: [{
|
|
77
|
+
name: "p",
|
|
78
|
+
onAction({ name }) { log.push(`before:${name}`); },
|
|
79
|
+
onActionDone({ name, result }) { log.push(`done:${name}:${String(result)}`); },
|
|
80
|
+
}],
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
(s as unknown as { increment: () => void }).increment();
|
|
84
|
+
expect(log).toEqual(["before:increment", "run", "done:increment:undefined"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("onActionDone resolves with value for async actions", async () => {
|
|
88
|
+
const results: StoreActionResult[] = [];
|
|
89
|
+
const s = createStore(
|
|
90
|
+
{ async fetch() { return 42; } },
|
|
91
|
+
{ plugins: [{ name: "p", onActionDone(r) { results.push(r); } }] },
|
|
92
|
+
)();
|
|
93
|
+
|
|
94
|
+
await (s as unknown as { fetch: () => Promise<number> }).fetch();
|
|
95
|
+
expect(results[0].result).toBe(42);
|
|
96
|
+
expect(results[0].error).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("onActionDone receives error when async action rejects", async () => {
|
|
100
|
+
const results: StoreActionResult[] = [];
|
|
101
|
+
const boom = new Error("boom");
|
|
102
|
+
const s = createStore(
|
|
103
|
+
{ async fail() { throw boom; } },
|
|
104
|
+
{ plugins: [{ name: "p", onActionDone(r) { results.push(r); } }] },
|
|
105
|
+
)();
|
|
106
|
+
|
|
107
|
+
await expect((s as unknown as { fail: () => Promise<void> }).fail()).rejects.toThrow("boom");
|
|
108
|
+
expect(results[0].error).toBe(boom);
|
|
109
|
+
expect(results[0].result).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("onInit extend() adds properties to the store", () => {
|
|
113
|
+
const s = createStore({ count: 0 }, {
|
|
114
|
+
plugins: [{
|
|
115
|
+
name: "p",
|
|
116
|
+
onInit({ extend }) { extend({ $version: "2.0" }); },
|
|
117
|
+
}],
|
|
118
|
+
})();
|
|
119
|
+
|
|
120
|
+
expect((s as unknown as { $version: string }).$version).toBe("2.0");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("extended functions are bound to the store proxy", () => {
|
|
124
|
+
let capturedStore: unknown;
|
|
125
|
+
const s = createStore({ count: 0 }, {
|
|
126
|
+
plugins: [{
|
|
127
|
+
name: "p",
|
|
128
|
+
onInit({ extend }) {
|
|
129
|
+
extend({ $self(this: unknown) { capturedStore = this; } });
|
|
130
|
+
},
|
|
131
|
+
}],
|
|
132
|
+
})();
|
|
133
|
+
|
|
134
|
+
(s as unknown as { $self: () => void }).$self();
|
|
135
|
+
expect(capturedStore).toBe(s);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("multiple per-store plugins run in order", () => {
|
|
139
|
+
const log: string[] = [];
|
|
140
|
+
const s = createStore({ x: 0 }, {
|
|
141
|
+
plugins: [
|
|
142
|
+
{ name: "a", onMutation() { log.push("a"); } },
|
|
143
|
+
{ name: "b", onMutation() { log.push("b"); } },
|
|
144
|
+
],
|
|
145
|
+
})();
|
|
146
|
+
|
|
147
|
+
(s as unknown as { x: number }).x = 1;
|
|
148
|
+
expect(log).toEqual(["a", "b"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("$subscribe, $reset, $patch, $state are NOT tracked as actions", () => {
|
|
152
|
+
const actions: string[] = [];
|
|
153
|
+
const s = createStore({ count: 0 }, {
|
|
154
|
+
plugins: [{ name: "p", onAction({ name }) { actions.push(name); } }],
|
|
155
|
+
})();
|
|
156
|
+
|
|
157
|
+
(s as unknown as { $state: () => unknown }).$state();
|
|
158
|
+
(s as unknown as { $patch: (p: Record<string, unknown>) => void }).$patch({ count: 1 });
|
|
159
|
+
(s as unknown as { $reset: () => void }).$reset();
|
|
160
|
+
expect(actions).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("no plugins — store behaves identically to baseline", () => {
|
|
164
|
+
const s = createStore({ count: 0, increment() { (this as unknown as { count: number }).count++; } })();
|
|
165
|
+
(s as unknown as { increment: () => void }).increment();
|
|
166
|
+
expect(s.count).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── createStore — global plugins ─────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
describe("createStore — global plugins (useStorePlugin)", () => {
|
|
173
|
+
it("global plugin fires for any store created after registration", () => {
|
|
174
|
+
const storenames: string[] = [];
|
|
175
|
+
useStorePlugin({ name: "g", onInit({ storeName }) { storenames.push(storeName); } });
|
|
176
|
+
|
|
177
|
+
createStore({ x: 0 }, { name: "alpha" });
|
|
178
|
+
createStore({ y: 0 }, { name: "beta" });
|
|
179
|
+
expect(storenames).toEqual(["alpha", "beta"]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("global + per-store plugins both fire: global first", () => {
|
|
183
|
+
const log: string[] = [];
|
|
184
|
+
useStorePlugin({ name: "global", onMutation() { log.push("global"); } });
|
|
185
|
+
|
|
186
|
+
const s = createStore({ x: 0 }, {
|
|
187
|
+
plugins: [{ name: "local", onMutation() { log.push("local"); } }],
|
|
188
|
+
})();
|
|
189
|
+
|
|
190
|
+
(s as unknown as { x: number }).x = 1;
|
|
191
|
+
expect(log).toEqual(["global", "local"]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("plugin registered after createStore does NOT receive events", () => {
|
|
195
|
+
const mutations: number[] = [];
|
|
196
|
+
const s = createStore({ x: 0 }, { name: "s" })();
|
|
197
|
+
useStorePlugin({ name: "late", onMutation() { mutations.push(1); } });
|
|
198
|
+
|
|
199
|
+
(s as unknown as { x: number }).x = 5;
|
|
200
|
+
expect(mutations).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ── class-based stores — per-store plugins ────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
describe("@Storable({ plugins }) — per-store plugins", () => {
|
|
207
|
+
it("onInit receives the class name as storeName", () => {
|
|
208
|
+
let received = "";
|
|
209
|
+
class SessionStore extends ReactiveStore { token = ""; }
|
|
210
|
+
Storable({ plugins: [{ name: "p", onInit({ storeName }) { received = storeName; } }] })(
|
|
211
|
+
SessionStore, {} as ClassDecoratorContext,
|
|
212
|
+
);
|
|
213
|
+
store(SessionStore);
|
|
214
|
+
expect(received).toBe("SessionStore");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("onMutation fires when a property is set on the singleton", () => {
|
|
218
|
+
const mutations: StoreMutation[] = [];
|
|
219
|
+
class FlagsStore extends ReactiveStore { enabled = false; }
|
|
220
|
+
Storable({ plugins: [{ name: "p", onMutation(m) { mutations.push(m); } }] })(
|
|
221
|
+
FlagsStore, {} as ClassDecoratorContext,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const s = store(FlagsStore);
|
|
225
|
+
s.enabled = true;
|
|
226
|
+
|
|
227
|
+
const m = mutations.find((x) => x.key === "enabled");
|
|
228
|
+
expect(m?.value).toBe(true);
|
|
229
|
+
expect(m?.prevValue).toBe(false);
|
|
230
|
+
expect(m?.storeName).toBe("FlagsStore");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("onAction and onActionDone fire for store methods", () => {
|
|
234
|
+
const log: string[] = [];
|
|
235
|
+
class CounterStore2 extends ReactiveStore {
|
|
236
|
+
count = 0;
|
|
237
|
+
increment() { this.count++; }
|
|
238
|
+
}
|
|
239
|
+
Storable({
|
|
240
|
+
plugins: [{
|
|
241
|
+
name: "p",
|
|
242
|
+
onAction({ name }) { log.push(`before:${name}`); },
|
|
243
|
+
onActionDone({ name }) { log.push(`done:${name}`); },
|
|
244
|
+
}],
|
|
245
|
+
})(CounterStore2, {} as ClassDecoratorContext);
|
|
246
|
+
|
|
247
|
+
store(CounterStore2).increment();
|
|
248
|
+
expect(log).toContain("before:increment");
|
|
249
|
+
expect(log).toContain("done:increment");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("onInit extend() — non-function value is returned directly", () => {
|
|
253
|
+
class TagStore extends ReactiveStore {}
|
|
254
|
+
Storable({ plugins: [{ name: "p", onInit({ extend }) { extend({ $tag: "ok" }); } }] })(
|
|
255
|
+
TagStore, {} as ClassDecoratorContext,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect((store(TagStore) as unknown as { $tag: string }).$tag).toBe("ok");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("onInit extend() — function value is bound to the proxy", () => {
|
|
262
|
+
let capturedThis: unknown;
|
|
263
|
+
class SelfStore extends ReactiveStore {}
|
|
264
|
+
Storable({
|
|
265
|
+
plugins: [{
|
|
266
|
+
name: "p",
|
|
267
|
+
onInit({ extend }) {
|
|
268
|
+
extend({ $self(this: unknown) { capturedThis = this; } });
|
|
269
|
+
},
|
|
270
|
+
}],
|
|
271
|
+
})(SelfStore, {} as ClassDecoratorContext);
|
|
272
|
+
|
|
273
|
+
const s = store(SelfStore);
|
|
274
|
+
(s as unknown as { $self(): void }).$self();
|
|
275
|
+
expect(capturedThis).toBe(s);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("onMutation does NOT fire when Reflect.set returns false (non-writable property)", () => {
|
|
279
|
+
const mutations: string[] = [];
|
|
280
|
+
class ReadonlyStore extends ReactiveStore {
|
|
281
|
+
get fixed() { return 42; }
|
|
282
|
+
}
|
|
283
|
+
Storable({ plugins: [{ name: "p", onMutation({ key }) { mutations.push(key); } }] })(
|
|
284
|
+
ReadonlyStore, {} as ClassDecoratorContext,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const s = store(ReadonlyStore);
|
|
288
|
+
// `fixed` has no setter — Reflect.set returns false, onMutation must NOT fire
|
|
289
|
+
Reflect.set(s as object, "fixed", 99);
|
|
290
|
+
expect(mutations).not.toContain("fixed");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("$-prefixed methods are NOT tracked as actions", () => {
|
|
294
|
+
const actions: string[] = [];
|
|
295
|
+
class OpStore extends ReactiveStore {
|
|
296
|
+
value = 0;
|
|
297
|
+
$internal() { this.value = 1; }
|
|
298
|
+
}
|
|
299
|
+
Storable({ plugins: [{ name: "p", onAction({ name }) { actions.push(name); } }] })(
|
|
300
|
+
OpStore, {} as ClassDecoratorContext,
|
|
301
|
+
);
|
|
302
|
+
store(OpStore).$internal();
|
|
303
|
+
expect(actions).not.toContain("$internal");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("onActionDone fires with resolved value for async methods (lines 30-35 in apply-plugins)", async () => {
|
|
307
|
+
const results: StoreActionResult[] = [];
|
|
308
|
+
class AsyncStore extends ReactiveStore {
|
|
309
|
+
async fetch() { return 99; }
|
|
310
|
+
}
|
|
311
|
+
Storable({ plugins: [{ name: "p", onActionDone(r) { results.push(r); } }] })(
|
|
312
|
+
AsyncStore, {} as ClassDecoratorContext,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
await store(AsyncStore).fetch();
|
|
316
|
+
expect(results[0].result).toBe(99);
|
|
317
|
+
expect(results[0].error).toBeUndefined();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("onActionDone fires with error when async method rejects (lines 36-39 in apply-plugins)", async () => {
|
|
321
|
+
const results: StoreActionResult[] = [];
|
|
322
|
+
const boom = new Error("async-fail");
|
|
323
|
+
class FailStore extends ReactiveStore {
|
|
324
|
+
async fail() { throw boom; }
|
|
325
|
+
}
|
|
326
|
+
Storable({ plugins: [{ name: "p", onActionDone(r) { results.push(r); } }] })(
|
|
327
|
+
FailStore, {} as ClassDecoratorContext,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
await expect(store(FailStore).fail()).rejects.toThrow("async-fail");
|
|
331
|
+
expect(results[0].error).toBe(boom);
|
|
332
|
+
expect(results[0].result).toBeUndefined();
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("setting a symbol key on the proxy is a no-op and returns true (lines 53-54 in apply-plugins)", () => {
|
|
336
|
+
class SymStore extends ReactiveStore { value = 0; }
|
|
337
|
+
Storable({ plugins: [{ name: "p", onMutation() { /* intentionally empty */ } }] })(
|
|
338
|
+
SymStore, {} as ClassDecoratorContext,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const s = store(SymStore);
|
|
342
|
+
const sym = Symbol("test");
|
|
343
|
+
expect(() => {
|
|
344
|
+
(s as unknown as Record<symbol, unknown>)[sym] = "ignored";
|
|
345
|
+
}).not.toThrow();
|
|
346
|
+
expect((s as unknown as Record<symbol, unknown>)[sym]).toBe("ignored");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("no plugins — class-based store works identically to baseline", () => {
|
|
350
|
+
class BareStore extends ReactiveStore { value = 7; }
|
|
351
|
+
Storable()(BareStore, {} as ClassDecoratorContext);
|
|
352
|
+
const s = store(BareStore);
|
|
353
|
+
s.value = 42;
|
|
354
|
+
expect(s.value).toBe(42);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ── class-based stores — global plugins ───────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
describe("class-based store — global plugins", () => {
|
|
361
|
+
it("global plugin fires for class-based stores", () => {
|
|
362
|
+
const names: string[] = [];
|
|
363
|
+
useStorePlugin({ name: "g", onInit({ storeName }) { names.push(storeName); } });
|
|
364
|
+
|
|
365
|
+
class GlobalTestStore extends ReactiveStore {}
|
|
366
|
+
Storable()(GlobalTestStore, {} as ClassDecoratorContext);
|
|
367
|
+
store(GlobalTestStore);
|
|
368
|
+
|
|
369
|
+
expect(names).toContain("GlobalTestStore");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("global + per-store plugins both fire: global first", () => {
|
|
373
|
+
const log: string[] = [];
|
|
374
|
+
useStorePlugin({ name: "global", onMutation() { log.push("global"); } });
|
|
375
|
+
|
|
376
|
+
class MixStore extends ReactiveStore { x = 0; }
|
|
377
|
+
Storable({ plugins: [{ name: "local", onMutation() { log.push("local"); } }] })(
|
|
378
|
+
MixStore, {} as ClassDecoratorContext,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
store(MixStore).x = 1;
|
|
382
|
+
expect(log).toEqual(["global", "local"]);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ── real-world plugin patterns ────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
describe("built-in plugin patterns", () => {
|
|
389
|
+
it("logger plugin: records mutations and action calls", () => {
|
|
390
|
+
const log: string[] = [];
|
|
391
|
+
const logger: StorePlugin = {
|
|
392
|
+
name: "logger",
|
|
393
|
+
onMutation({ storeName, key, value }) {
|
|
394
|
+
log.push(`[${storeName}] ${key} = ${JSON.stringify(value)}`);
|
|
395
|
+
},
|
|
396
|
+
onAction({ storeName, name }) {
|
|
397
|
+
log.push(`[${storeName}] ${name}()`);
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const s = createStore({
|
|
402
|
+
count: 0,
|
|
403
|
+
increment() { (this as unknown as { count: number }).count++; },
|
|
404
|
+
}, { name: "counter", plugins: [logger] })();
|
|
405
|
+
|
|
406
|
+
(s as unknown as { increment: () => void }).increment();
|
|
407
|
+
expect(log).toContain("[counter] increment()");
|
|
408
|
+
expect(log).toContain('[counter] count = 1');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("persistence plugin: restores state onInit and saves onMutation", () => {
|
|
412
|
+
const storage: Record<string, unknown> = { count: 99 };
|
|
413
|
+
let savedCount: unknown;
|
|
414
|
+
|
|
415
|
+
const persistPlugin: StorePlugin = {
|
|
416
|
+
name: "persist",
|
|
417
|
+
onInit({ store: s, storeName }) {
|
|
418
|
+
if (storeName === "counter" && typeof storage.count === "number") {
|
|
419
|
+
(s as { count: number }).count = storage.count as number;
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
onMutation({ key, value }) {
|
|
423
|
+
if (key === "count") savedCount = value;
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const s = createStore({ count: 0 }, { name: "counter", plugins: [persistPlugin] })();
|
|
428
|
+
expect((s as unknown as { count: number }).count).toBe(99);
|
|
429
|
+
|
|
430
|
+
(s as unknown as { count: number }).count = 55;
|
|
431
|
+
expect(savedCount).toBe(55);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("spy plugin: action wrapping does not break return values", () => {
|
|
435
|
+
const spy = vi.fn();
|
|
436
|
+
const s = createStore(
|
|
437
|
+
{ double(n: unknown) { return (n as number) * 2; } },
|
|
438
|
+
{ plugins: [{ name: "spy", onActionDone({ name, result }) { spy(name, result); } }] },
|
|
439
|
+
)();
|
|
440
|
+
|
|
441
|
+
const result = (s as unknown as { double: (n: number) => number }).double(5);
|
|
442
|
+
expect(result).toBe(10);
|
|
443
|
+
expect(spy).toHaveBeenCalledWith("double", 10);
|
|
444
|
+
});
|
|
445
|
+
});
|