@iyulab/enterprise 0.16.0 → 0.17.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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.0] - 2026-09-23
4
+
5
+ ### Added
6
+
7
+ - **`ApiError.notified` and `wasNotified(err)` — whether the service already told the user about a
8
+ failure.** A failed write (`odataPost/Patch/Delete`, `apiPost/Put/Patch/Delete`) is reported
9
+ through `notify.error` and then rethrown, so the caller's flow stops. When that error reaches a
10
+ global `unhandledrejection` handler or an error boundary, the boundary could not tell it from a
11
+ failed read, which the service does not report: toasting again showed the message twice, not
12
+ toasting left read failures silent. The error now carries the answer. `notified` is `true` only
13
+ when the service actually called `notify.error` — reads, `*Quiet` writes, 401s and services
14
+ without `notify.error` give `false` — and it is read-only: only the service can set it.
15
+ `wasNotified(err)` answers the same for failures that are not `ApiError`, such as a network
16
+ error on a write.
17
+
3
18
  ## [0.16.0] - 2026-09-22
4
19
 
5
20
  ### Documentation
package/README.md CHANGED
@@ -19,6 +19,7 @@ npm install @iyulab/enterprise
19
19
  | `ApiConfig` | class | baseUrl/OData·API prefix·dev 판별 중앙 설정 |
20
20
  | `createODataService` | factory | OData v4 + custom REST CRUD 서비스(401·토스트·에러파싱) |
21
21
  | `ApiError` | class | HTTP status + OData `error.details`(필드별 검증 상세)를 실은 API 호출 실패 에러 |
22
+ | `wasNotified` | function | 이 실패를 서비스가 `notify.error` 로 이미 알렸는가 — 경계의 이중 토스트 방지 |
22
23
  | `createAuthClient` | factory | 쿠키 세션 인증(fetchMe/login/logout) — 제네릭 user/자격증명 |
23
24
  | `createPermissionStore` · `hasPermission` 외 | store | 권한 스냅샷 store + 판정 free 함수 |
24
25
  | `CurrencyHelper` | class | 통화 포맷(`formatKRW` 등) |
@@ -208,6 +209,26 @@ try {
208
209
  | `message` | 사용자 대면 메시지 (`formatError` → 서버 raw → status 폴백 순으로 결정) |
209
210
  | `status` | HTTP status |
210
211
  | `details` | `error.details` 항목 배열 — 규격상 `code`/`message` 는 필수, `target`(속성 이름)은 선택. 상세가 없거나 규격 형태가 아니면 `undefined` |
212
+ | `notified` | 서비스가 이 실패를 `notify.error` 로 **이미 사용자에게 알렸는가**(읽기 전용). 쓰기 실패를 알렸으면 `true`, 읽기·`*Quiet`·401 이나 `notify.error` 미설정이면 `false` |
213
+
214
+ #### 경계에서 «알리지 않은 것만» 알리기 — `notified` · `wasNotified`
215
+
216
+ 쓰기 실패는 서비스가 알린 **뒤** 같은 에러를 다시 던진다(호출부 흐름을 멈추게 하려고). 그 에러가 전역
217
+ `unhandledrejection` 핸들러나 error boundary 까지 올라오면, 거기서 다시 토스트를 띄우면 이중 통지가 되고
218
+ 안 띄우면 읽기 실패가 조용해진다. 어느 쪽인지는 에러가 들고 온다:
219
+
220
+ ```typescript
221
+ import { ApiError, wasNotified } from '@iyulab/enterprise'
222
+
223
+ window.addEventListener('unhandledrejection', (ev) => {
224
+ const err = ev.reason
225
+ if (err instanceof ApiError || wasNotified(err)) ev.preventDefault()
226
+ if (err instanceof ApiError && !err.notified) app.error(err.message) // 읽기 실패 등
227
+ })
228
+ ```
229
+
230
+ `wasNotified(err)` 는 `ApiError` 가 아닌 실패(네트워크 오류 등)에도 같은 답을 준다 — 쓰기 경로에서 알렸다면
231
+ `true`. 표식은 서비스만 세울 수 있다(에러 객체의 필드가 아니다).
211
232
 
212
233
 
213
234
  ### 인증 + 권한 (`createAuthClient` · 권한 store)
package/dist/index.d.ts CHANGED
@@ -86,6 +86,11 @@ export declare class ApiError extends Error {
86
86
  /** OData v4 오류 봉투의 `error.details`(필드별 검증 상세) — 서버 응답에 없거나 파싱 실패면 undefined. */
87
87
  readonly details?: ApiErrorDetail[];
88
88
  constructor(message: string, status: number, details?: ApiErrorDetail[]);
89
+ /**
90
+ * 서비스가 이 실패를 `notify.error` 로 **이미 사용자에게 알렸는가** — 경계가 «알리지 않은 것만» 알리는 근거.
91
+ * `notify.error` 를 설정하지 않은 서비스에서는 알린 것이 없으므로 항상 `false` 다. 읽기 전용이다.
92
+ */
93
+ get notified(): boolean;
89
94
  }
90
95
 
91
96
  /**
@@ -657,4 +662,14 @@ export declare class UrgencyHelper {
657
662
  */
658
663
  export declare type UrgencyLevel = 'overdue' | 'critical' | 'urgent' | 'soon' | 'normal';
659
664
 
665
+ /**
666
+ * 이 실패를 서비스가 `notify.error` 로 이미 사용자에게 알렸는가.
667
+ *
668
+ * 쓰기(`odataPost/Patch/Delete`·`apiPost/Put/Patch/Delete`) 실패는 알린 뒤 다시 던지고, 읽기·`*Quiet`·401 은
669
+ * 알리지 않는다 — 그 정책의 결과를 경계(전역 `unhandledrejection` 핸들러·error boundary)가 서비스 인스턴스
670
+ * 없이 읽는 자리다: `if (!wasNotified(err)) toast(message)`. `ApiError` 는 같은 사실을 `err.notified` 로도 준다.
671
+ * 네트워크 실패처럼 `ApiError` 가 아닌 것도 쓰기 경로에서 알렸다면 `true` 다.
672
+ */
673
+ export declare function wasNotified(error: unknown): boolean;
674
+
660
675
  export { }
package/dist/index.js CHANGED
@@ -645,6 +645,22 @@ function extractErrorDetails(raw) {
645
645
  return details.length > 0 ? details : void 0;
646
646
  }
647
647
  /**
648
+ * 서비스가 `notify.error` 로 **사용자에게 이미 알린** 실패들 — 이 모듈만 채운다.
649
+ * ⚠에러 객체의 필드로 두지 않는다: 소비자가 세우거나 지울 수 있으면 표식이 «알렸다» 를 뜻하지 않게 된다.
650
+ */
651
+ var notifiedFailures = /* @__PURE__ */ new WeakSet();
652
+ /**
653
+ * 이 실패를 서비스가 `notify.error` 로 이미 사용자에게 알렸는가.
654
+ *
655
+ * 쓰기(`odataPost/Patch/Delete`·`apiPost/Put/Patch/Delete`) 실패는 알린 뒤 다시 던지고, 읽기·`*Quiet`·401 은
656
+ * 알리지 않는다 — 그 정책의 결과를 경계(전역 `unhandledrejection` 핸들러·error boundary)가 서비스 인스턴스
657
+ * 없이 읽는 자리다: `if (!wasNotified(err)) toast(message)`. `ApiError` 는 같은 사실을 `err.notified` 로도 준다.
658
+ * 네트워크 실패처럼 `ApiError` 가 아닌 것도 쓰기 경로에서 알렸다면 `true` 다.
659
+ */
660
+ function wasNotified(error) {
661
+ return typeof error === "object" && error !== null && notifiedFailures.has(error);
662
+ }
663
+ /**
648
664
  * API 호출 실패 에러 — HTTP status 를 실어 호출부가 상태별 분기(예: 404 도메인 문구)를 할 수 있게 한다.
649
665
  * `Error` 를 상속하므로 기존 `e instanceof Error`/`e.message` 소비처는 그대로 동작한다.
650
666
  */
@@ -655,6 +671,13 @@ var ApiError = class extends Error {
655
671
  this.status = status;
656
672
  this.details = details;
657
673
  }
674
+ /**
675
+ * 서비스가 이 실패를 `notify.error` 로 **이미 사용자에게 알렸는가** — 경계가 «알리지 않은 것만» 알리는 근거.
676
+ * `notify.error` 를 설정하지 않은 서비스에서는 알린 것이 없으므로 항상 `false` 다. 읽기 전용이다.
677
+ */
678
+ get notified() {
679
+ return notifiedFailures.has(this);
680
+ }
658
681
  };
659
682
  var DEFAULT_MESSAGES = {
660
683
  saved: "Saved",
@@ -798,7 +821,10 @@ function createODataService(config) {
798
821
  try {
799
822
  return await run();
800
823
  } catch (e) {
801
- if (!(e instanceof ApiError && e.status === 401)) notifyError?.(e instanceof Error ? e.message : messages.requestFailed);
824
+ if (notifyError && !(e instanceof ApiError && e.status === 401)) {
825
+ notifyError(e instanceof Error ? e.message : messages.requestFailed);
826
+ if (typeof e === "object" && e !== null) notifiedFailures.add(e);
827
+ }
802
828
  throw e;
803
829
  }
804
830
  }
@@ -1074,4 +1100,4 @@ function createAuthClient(config) {
1074
1100
  };
1075
1101
  }
1076
1102
  //#endregion
1077
- export { ApiConfig, ApiError, CurrencyHelper, DateHelper, EMPTY_VALUE_DISPLAY, FormRow, FormSection, ProgressHelper, UrgencyHelper, clearPermissions, createAuthClient, createODataService, createPermissionStore, defaultPermissionStore, getPermissions, hasAllPermissions, hasAnyPermission, hasPermission, messages, setPermissions };
1103
+ export { ApiConfig, ApiError, CurrencyHelper, DateHelper, EMPTY_VALUE_DISPLAY, FormRow, FormSection, ProgressHelper, UrgencyHelper, clearPermissions, createAuthClient, createODataService, createPermissionStore, defaultPermissionStore, getPermissions, hasAllPermissions, hasAnyPermission, hasPermission, messages, setPermissions, wasNotified };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/enterprise",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Enterprise utilities and components for iyulab framework",
5
5
  "keywords": [
6
6
  "enterprise",