@iyulab/enterprise 0.13.0 → 0.14.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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.0] - 2026-09-20
4
+
5
+ ### Fixed
6
+
7
+ - **A failed `api*` write told the user nothing.** `notify.error` was wired into the OData writes
8
+ only, so a custom REST write that came back 4xx threw an `ApiError` and left the screen exactly as
9
+ it was — indistinguishable from nothing having happened, even when the server had returned a
10
+ precise reason. The four `api*` writes now notify on failure and rethrow, on the same terms as
11
+ their OData siblings (401 excluded, since `onUnauthorized` already covers it). Reads are unchanged
12
+ and still silent on both sides.
13
+
14
+ ### Added
15
+
16
+ - **`apiPostQuiet`, `apiPutQuiet`, `apiPatchQuiet`, `apiDeleteQuiet`.** The opt-out for the above,
17
+ matching the existing `odata*Quiet` naming: use them when one user action issues several requests,
18
+ or when the calling code already reports failures itself and would otherwise show two messages.
19
+
20
+ ### Documentation
21
+
22
+ - **The README said which hooks exist but not where they apply**, which read as "wire `notify` and
23
+ failures become visible". It now carries a per-method table and states the axis: writes notify,
24
+ reads do not, `*Quiet` opts out, 401 never notifies, and `api*` has no success message because the
25
+ library cannot invent wording for an arbitrary endpoint.
26
+
3
27
  ## 0.13.0
4
28
 
5
29
  ### Changed
package/README.md CHANGED
@@ -121,10 +121,29 @@ await svc.apiPost<Order>('orders/7/attachments', form)
121
121
  | `baseUrl` | 모든 요청의 오리진 (필수) |
122
122
  | `odataPrefix` / `apiPrefix` | 엔드포인트 prefix (기본 `$data` / `api`) |
123
123
  | `onUnauthorized(status)` | 401 시 호출 — 리다이렉트/재진입 가드는 앱이 처리 |
124
- | `notify.success/error` | 토스트 훅 (생략 시 토스트 없음 — 순수) |
124
+ | `notify.success/error` | 토스트 훅 (생략 시 토스트 없음 — 순수). **메서드마다 걸리는 방식이 다르다 — 바로 아래 표 참조** |
125
125
  | `messages` | 사용자 대면 문구 (기본 영어, 지정 키만 대체) |
126
126
  | `formatError(info)` | 에러 메시지 포매팅 오버라이드 (앱별 정책) — `info` 는 `status`/`statusText`/`rawMessage`/`details`(검증된 `error.details`)/`body` 를 받는다 |
127
127
 
128
+ #### `notify` 가 걸리는 자리 — 축은 «쓰기 ↔ 읽기» 다
129
+
130
+ | 메서드 | 실패 시 `error` | 성공 시 `success` |
131
+ |---|---|---|
132
+ | `odataPost` · `odataPatch` · `odataDelete` | ✅ (401 제외) | ✅ `saved`·`updated`·`deleted` |
133
+ | `apiPost` · `apiPut` · `apiPatch` · `apiDelete` | ✅ (401 제외) | ❌ |
134
+ | `*Quiet` 전부 (`odataPostQuiet` … `apiDeleteQuiet`) | ❌ | ❌ |
135
+ | `odataGet` · `odataGetById` · `odataCount` · `apiGet` · `fetchRaw` | ❌ | ❌ |
136
+
137
+ - **조회는 통지하지 않는다.** 빈 화면 자체가 신호이고, 목록을 열 때마다 토스트가 뜨면 읽을 수 없다.
138
+ - **쓰기는 통지한다.** 결과가 화면에 안 보일 수 있기 때문이다 — 서버가 409 와 사유를 돌려줘도,
139
+ 통지가 없으면 아무 일도 일어나지 않은 화면과 구별되지 않는다.
140
+ - **401 은 통지하지 않는다** — `onUnauthorized` 가 이미 안내하므로 겹친다.
141
+ - **`api*` 에는 성공 토스트가 없다.** 임의의 RPC(상태 전이·발행·업로드)를 태우는 경로라
142
+ «저장되었습니다» 같은 문구를 라이브러리가 지어낼 수 없다. 실패 메시지는 서버가 주므로 어느
143
+ 엔드포인트에서나 뜻이 통하지만, 성공 문구는 그렇지 않다 — 필요하면 호출한 쪽이 띄운다.
144
+ - **이미 자기 래퍼로 통지하고 있다면 `*Quiet` 로 바꾼다.** 이중 토스트를 막는 탈출구이고,
145
+ `odata*Quiet` 와 같은 관용구다(한 사용자 액션이 여러 요청을 낼 때도 같은 것을 쓴다).
146
+
128
147
  > 도메인 액션(상태 전이 등)·엔티티 목록·권한 코드는 라이브러리에 넣지 말고 앱 adapter 에 둔다.
129
148
 
130
149
  #### 실패 응답 — `ApiError`
@@ -94,16 +94,28 @@ export interface ODataService {
94
94
  odataDelete(entity: string, id: string): Promise<void>;
95
95
  /** custom REST GET — 204 등 빈 바디를 안전 파싱. */
96
96
  apiGet<T>(path: string): Promise<T>;
97
- /** custom REST POST. `body`가 `FormData` 인스턴스면 그대로(직렬화 없이) 멀티파트로
98
- * 전송된다 — `@iyulab/http-client`가 Content-Type을 브라우저 자동 설정에 맡기고
99
- * JSON 직렬화 분기를 타지 않는다. `apiPut`/`apiPatch`도 동일하게 동작한다. */
97
+ /** custom REST POST 실패 시 `error` 토스트 후 rethrow(401 제외). `body`가 `FormData`
98
+ * 인스턴스면 그대로(직렬화 없이) 멀티파트로 전송된다 — `@iyulab/http-client`가
99
+ * Content-Type을 브라우저 자동 설정에 맡기고 JSON 직렬화 분기를 타지 않는다.
100
+ * `apiPut`/`apiPatch`도 동일하게 동작한다. */
100
101
  apiPost<T>(path: string, body?: unknown): Promise<T>;
101
- /** custom REST PUT(리소스 전체 교체/생성). `body`의 `FormData` 처리는 `apiPost` 참조. */
102
+ /** custom REST POST 토스트 없이 결과만(`odataPostQuiet`와 같은 이유: 사용자 액션이
103
+ * 여러 요청을 내거나, 소비자가 자기 래퍼로 이미 통지할 때). */
104
+ apiPostQuiet<T>(path: string, body?: unknown): Promise<T>;
105
+ /** custom REST PUT(리소스 전체 교체/생성) — 실패 시 `error` 토스트 후 rethrow(401 제외).
106
+ * `body`의 `FormData` 처리는 `apiPost` 참조. */
102
107
  apiPut<T>(path: string, body?: unknown): Promise<T>;
103
- /** custom REST PATCH. `body`의 `FormData` 처리는 `apiPost` 참조. */
108
+ /** custom REST PUT 토스트 없이 결과만. */
109
+ apiPutQuiet<T>(path: string, body?: unknown): Promise<T>;
110
+ /** custom REST PATCH — 실패 시 `error` 토스트 후 rethrow(401 제외). `body`의 `FormData`
111
+ * 처리는 `apiPost` 참조. */
104
112
  apiPatch<T>(path: string, body?: unknown): Promise<T>;
105
- /** custom REST DELETE대부분 204 No Content. */
113
+ /** custom REST PATCH토스트 없이 결과만. */
114
+ apiPatchQuiet<T>(path: string, body?: unknown): Promise<T>;
115
+ /** custom REST DELETE — 실패 시 `error` 토스트 후 rethrow(401 제외). 대부분 204 No Content. */
106
116
  apiDelete<T = void>(path: string): Promise<T>;
117
+ /** custom REST DELETE — 토스트 없이 결과만. */
118
+ apiDeleteQuiet<T = void>(path: string): Promise<T>;
107
119
  /** URL 을 직접 조립한 커스텀 조회(csv-export 등)를 위해 raw 응답을 반환. */
108
120
  fetchRaw(url: string): Promise<HttpResponse>;
109
121
  /**
package/dist/index.js CHANGED
@@ -774,44 +774,58 @@ function createODataService(config) {
774
774
  await throwIfError(res);
775
775
  return (await res.json())["@odata.count"] ?? 0;
776
776
  }
777
+ /**
778
+ * 쓰기 실패를 사용자에게 알린 뒤 그대로 다시 던진다.
779
+ *
780
+ * 🔴**이 서비스의 통지 축은 «쓰기 ↔ 읽기» 이지 «odata ↔ api» 가 아니다.** 조회
781
+ * (`odataGet`·`odataGetById`·`odataCount`·`apiGet`·`fetchRaw`)는 어느 쪽도 통지하지
782
+ * 않는다 — 화면이 비어 있는 것 자체가 신호이고, 목록 조회마다 토스트를 띄우면 읽을 수
783
+ * 없다. 쓰기는 **결과가 화면에 안 보일 수 있으므로** 통지한다: 서버가 409 와 사유를
784
+ * 돌려줘도 아무 일도 일어나지 않은 화면과 구별되지 않는다.
785
+ *
786
+ * ⚠**401 은 제외한다** — `onUnauthorized` 가 이미 안내하므로 토스트가 겹친다.
787
+ *
788
+ * ⚠성공 토스트는 **`odata*` 쓰기에만** 있다. `api*` 는 임의의 RPC(상태 전이·발행·업로드)를
789
+ * 태우는 경로라 «저장되었습니다» 같은 문구를 우리가 지어낼 수 없다 — 실패 메시지는 서버가
790
+ * 주므로 어느 엔드포인트에서나 뜻이 통하지만, 성공 문구는 그렇지 않다.
791
+ */
792
+ async function notifyingWrite(run) {
793
+ try {
794
+ return await run();
795
+ } catch (e) {
796
+ if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
797
+ throw e;
798
+ }
799
+ }
777
800
  async function odataPostQuiet(entity, body) {
778
801
  const res = await client.post(odataUrl(entity), normalizeBody(body));
779
802
  await throwIfError(res);
780
803
  return res.json();
781
804
  }
782
805
  async function odataPost(entity, body) {
783
- try {
806
+ return notifyingWrite(async () => {
784
807
  const result = await odataPostQuiet(entity, body);
785
808
  notifySuccess?.(messages.saved);
786
809
  return result;
787
- } catch (e) {
788
- if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
789
- throw e;
790
- }
810
+ });
791
811
  }
792
812
  async function odataPatchQuiet(entity, id, body) {
793
813
  await throwIfError(await client.patch(`${odataUrl(entity)}(${id})`, normalizeBody(body)));
794
814
  }
795
815
  async function odataPatch(entity, id, body) {
796
- try {
816
+ return notifyingWrite(async () => {
797
817
  await odataPatchQuiet(entity, id, body);
798
818
  notifySuccess?.(messages.updated);
799
- } catch (e) {
800
- if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
801
- throw e;
802
- }
819
+ });
803
820
  }
804
821
  async function odataDeleteQuiet(entity, id) {
805
822
  await throwIfError(await client.delete(`${odataUrl(entity)}(${id})`));
806
823
  }
807
824
  async function odataDelete(entity, id) {
808
- try {
825
+ return notifyingWrite(async () => {
809
826
  await odataDeleteQuiet(entity, id);
810
827
  notifySuccess?.(messages.deleted);
811
- } catch (e) {
812
- if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
813
- throw e;
814
- }
828
+ });
815
829
  }
816
830
  async function apiGet(path) {
817
831
  const [p, ...q] = path.split("?");
@@ -820,26 +834,38 @@ function createODataService(config) {
820
834
  await throwIfError(res);
821
835
  return parseJsonBody(res);
822
836
  }
823
- async function apiPost(path, body) {
837
+ async function apiPostQuiet(path, body) {
824
838
  const res = await client.post(apiUrl(path), body ?? {});
825
839
  await throwIfError(res);
826
840
  return parseJsonBody(res);
827
841
  }
828
- async function apiPut(path, body) {
842
+ async function apiPost(path, body) {
843
+ return notifyingWrite(() => apiPostQuiet(path, body));
844
+ }
845
+ async function apiPutQuiet(path, body) {
829
846
  const res = await client.put(apiUrl(path), body ?? {});
830
847
  await throwIfError(res);
831
848
  return parseJsonBody(res);
832
849
  }
833
- async function apiPatch(path, body) {
850
+ async function apiPut(path, body) {
851
+ return notifyingWrite(() => apiPutQuiet(path, body));
852
+ }
853
+ async function apiPatchQuiet(path, body) {
834
854
  const res = await client.patch(apiUrl(path), body ?? {});
835
855
  await throwIfError(res);
836
856
  return parseJsonBody(res);
837
857
  }
838
- async function apiDelete(path) {
858
+ async function apiPatch(path, body) {
859
+ return notifyingWrite(() => apiPatchQuiet(path, body));
860
+ }
861
+ async function apiDeleteQuiet(path) {
839
862
  const res = await client.delete(apiUrl(path));
840
863
  await throwIfError(res);
841
864
  return parseJsonBody(res);
842
865
  }
866
+ async function apiDelete(path) {
867
+ return notifyingWrite(() => apiDeleteQuiet(path));
868
+ }
843
869
  async function fetchRaw(url) {
844
870
  const res = await client.get(url);
845
871
  await throwIfError(res);
@@ -859,9 +885,13 @@ function createODataService(config) {
859
885
  odataDelete,
860
886
  apiGet,
861
887
  apiPost,
888
+ apiPostQuiet,
862
889
  apiPut,
890
+ apiPutQuiet,
863
891
  apiPatch,
892
+ apiPatchQuiet,
864
893
  apiDelete,
894
+ apiDeleteQuiet,
865
895
  fetchRaw,
866
896
  sourceDefaults: {
867
897
  baseUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/enterprise",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Enterprise utilities and components for iyulab framework",
5
5
  "keywords": [
6
6
  "enterprise",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^6.0.2",
62
62
  "vite": "^8.1.4",
63
63
  "vite-plugin-dts": "^5.0.3",
64
- "vitest": "^4.1.10"
64
+ "vitest": "^5.0.0"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "react": ">=18",