@mohasinac/appkit 4.30.16 → 4.30.17
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/dist/_internal/shared/types/action-error-reporter.d.ts +40 -0
- package/dist/_internal/shared/types/action-error-reporter.js +27 -0
- package/dist/_internal/shared/types/action-result.d.ts +10 -0
- package/dist/_internal/shared/types/action-result.js +26 -0
- package/dist/client/api/surface-error.d.ts +6 -4
- package/dist/client/api/surface-error.js +19 -8
- package/dist/contracts/realtime-session.d.ts +27 -0
- package/dist/contracts/realtime-session.js +118 -0
- package/dist/core/unit-of-work.js +21 -13
- package/dist/errors/base-error.js +14 -1
- package/dist/errors/database-error.js +11 -1
- package/dist/errors/describe-cause.d.ts +5 -0
- package/dist/errors/describe-cause.js +76 -0
- package/dist/errors/error-display-map.d.ts +32 -1
- package/dist/errors/error-display-map.js +46 -0
- package/dist/errors/error-mapping.d.ts +47 -7
- package/dist/errors/error-mapping.js +46 -5
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +4 -0
- package/dist/features/about/components/PublicProfileView.js +4 -4
- package/dist/features/account/components/AddressBook.js +2 -2
- package/dist/features/account/components/AddressesIndexListing.js +2 -2
- package/dist/features/admin/components/AdminAnalyticsView.js +2 -2
- package/dist/features/admin/components/AdminCouponsView.js +2 -2
- package/dist/features/admin/components/AdminGuideHubView.js +2 -2
- package/dist/features/admin/components/AdminMediaView.js +2 -2
- package/dist/features/admin/components/AdminViewCards.js +2 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +1 -1
- package/dist/features/auctions/components/MarketplaceAuctionGrid.d.ts +1 -2
- package/dist/features/auctions/components/MarketplaceAuctionGrid.js +8 -7
- package/dist/features/auctions/components/PlaceBidFormClient.js +32 -15
- package/dist/features/before-after/components/BeforeAfterGallery.js +2 -2
- package/dist/features/blog/components/BlogIndexListing.js +3 -3
- package/dist/features/blog/components/BlogListView.js +3 -3
- package/dist/features/blog/components/BlogPostView.js +2 -2
- package/dist/features/categories/components/BundleCollage.js +2 -2
- package/dist/features/categories/components/CategoriesIndexListing.js +3 -3
- package/dist/features/categories/components/CategoryBundlesListing.js +2 -2
- package/dist/features/categories/components/CategoryGrid.js +2 -2
- package/dist/features/categories/components/CategoryProductsListing.js +2 -2
- package/dist/features/categories/components/CategoryStoresListing.js +2 -2
- package/dist/features/collections/components/CollectionCard.js +2 -2
- package/dist/features/events/components/EventRafflesSection.js +2 -2
- package/dist/features/events/components/EventsIndexListing.js +2 -2
- package/dist/features/events/components/EventsListView.js +3 -3
- package/dist/features/homepage/components/FeaturedBundlesSection.js +2 -2
- package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
- package/dist/features/homepage/components/HomepageSkeleton.js +1 -1
- package/dist/features/homepage/components/SecurityHighlightsSection.js +1 -1
- package/dist/features/homepage/components/SocialFeedSection.js +2 -2
- package/dist/features/homepage/components/StatsCounterSection.js +1 -1
- package/dist/features/homepage/components/TrustBadges.js +2 -2
- package/dist/features/homepage/components/TrustFeaturesSection.js +1 -1
- package/dist/features/messages/hooks/useConversation.js +39 -15
- package/dist/features/messages/hooks/useConversations.js +36 -14
- package/dist/features/messages/repository/conversations.repository.d.ts +23 -0
- package/dist/features/messages/repository/conversations.repository.js +53 -0
- package/dist/features/pre-orders/components/PreOrdersIndexListing.js +2 -3
- package/dist/features/products/components/AuctionsIndexListing.js +2 -3
- package/dist/features/products/components/PrizeDrawCollage.js +2 -2
- package/dist/features/products/components/PrizeDrawsIndexListing.js +2 -3
- package/dist/features/products/components/PrizeDrawsSection.js +2 -2
- package/dist/features/products/components/ProductGrid.js +2 -7
- package/dist/features/products/components/ProductsIndexListing.js +3 -3
- package/dist/features/products/components/RelatedProducts.js +2 -2
- package/dist/features/promotions/components/CouponsIndexListing.js +1 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +2 -2
- package/dist/features/reviews/components/ReviewsListingPanel.js +2 -2
- package/dist/features/seller/components/SellerCouponsView.js +2 -2
- package/dist/features/seller/components/SellerDashboardView.js +3 -3
- package/dist/features/server-errors/schemas/firestore.d.ts +9 -0
- package/dist/features/stores/components/StoreAuctionsListing.js +2 -3
- package/dist/features/stores/components/StoreClassifiedsListing.js +2 -3
- package/dist/features/stores/components/StoreDigitalCodesListing.js +2 -3
- package/dist/features/stores/components/StoreGuideHubView.js +2 -2
- package/dist/features/stores/components/StoreLiveItemsListing.js +2 -3
- package/dist/features/stores/components/StorePreOrdersListing.js +2 -3
- package/dist/features/stores/components/StoreProductsListing.js +2 -2
- package/dist/features/stores/components/StoresIndexListing.js +3 -3
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +54 -1
- package/dist/next/api/routeHandler.js +27 -7
- package/dist/providers/db-firebase/base.repository.js +34 -18
- package/dist/react/hooks/useRealtimeEvent.js +22 -4
- package/dist/server.d.ts +2 -0
- package/dist/server.js +5 -0
- package/dist/styles.css +84 -20
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/tokens.css +7 -2
- package/dist/ui/components/Layout.d.ts +31 -18
- package/dist/ui/components/Layout.js +30 -19
- package/dist/ui/components/Layout.style.css +76 -17
- package/dist/ui/components/ListingLayout.d.ts +2 -0
- package/dist/ui/components/ListingLayout.js +16 -2
- package/dist/ui/components/StickyToolbar.js +3 -48
- package/dist/ui/components/toolbar-collapse.d.ts +44 -0
- package/dist/ui/components/toolbar-collapse.js +93 -0
- package/package.json +1 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registration seam for recording server-action failures.
|
|
3
|
+
*
|
|
4
|
+
* WHY A HOOK AND NOT A DIRECT IMPORT
|
|
5
|
+
* `wrapAction` lives in `_internal/shared/`, and the module that hosts it also
|
|
6
|
+
* exports `isOk` / `unwrap` — type guards that CLIENT components import. A
|
|
7
|
+
* static `serverErrorsRepository` import there would drag `firebase-admin`
|
|
8
|
+
* into the client bundle graph, which is exactly the Turbopack trap in
|
|
9
|
+
* CLAUDE.md Root Cause #6. So the recorder is injected from the server side
|
|
10
|
+
* instead, and this module stays dependency-free.
|
|
11
|
+
*
|
|
12
|
+
* WHERE IT IS REGISTERED
|
|
13
|
+
* `src/instrumentation.ts` — server-only and guaranteed to be loaded by
|
|
14
|
+
* Next.js. Deliberately NOT a client bootstrap component: `setErrorTracker`
|
|
15
|
+
* was registered that way, the component it lived in was orphaned during a
|
|
16
|
+
* refactor, and every error boundary silently lost its digest for months
|
|
17
|
+
* (CLAUDE.md Root Cause #78). A registration nothing imports is invisible to
|
|
18
|
+
* `tsc`, so `audit-observability-registration.mjs` asserts both hooks keep a
|
|
19
|
+
* reachable call site.
|
|
20
|
+
*/
|
|
21
|
+
export interface ActionErrorReport {
|
|
22
|
+
/** Stable error code from the mapped envelope. */
|
|
23
|
+
code: string;
|
|
24
|
+
/** The UNSCRUBBED message — never the client-facing one. */
|
|
25
|
+
message: string;
|
|
26
|
+
/** Wrapper stack. Begins at the wrap site; see `cause`. */
|
|
27
|
+
stack?: string;
|
|
28
|
+
/** Flattened `Error.cause` chain — where the real frames live. */
|
|
29
|
+
cause?: string;
|
|
30
|
+
}
|
|
31
|
+
export type ActionErrorReporter = (report: ActionErrorReport) => void;
|
|
32
|
+
export declare function setActionErrorReporter(fn: ActionErrorReporter): void;
|
|
33
|
+
/**
|
|
34
|
+
* Report a server-action failure. Never throws and never awaits — the caller is
|
|
35
|
+
* already on a failure path and must not be made slower or more fragile by
|
|
36
|
+
* observability.
|
|
37
|
+
*/
|
|
38
|
+
export declare function reportActionError(report: ActionErrorReport): void;
|
|
39
|
+
/** Test/introspection helper — true once a reporter has been installed. */
|
|
40
|
+
export declare function hasActionErrorReporter(): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { normalizeError } from "../../../errors/normalize";
|
|
2
|
+
let reporter = null;
|
|
3
|
+
export function setActionErrorReporter(fn) {
|
|
4
|
+
reporter = fn;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Report a server-action failure. Never throws and never awaits — the caller is
|
|
8
|
+
* already on a failure path and must not be made slower or more fragile by
|
|
9
|
+
* observability.
|
|
10
|
+
*/
|
|
11
|
+
export function reportActionError(report) {
|
|
12
|
+
if (!reporter)
|
|
13
|
+
return;
|
|
14
|
+
try {
|
|
15
|
+
reporter(report);
|
|
16
|
+
// This IS the error-reporting path. A reporter that throws must not turn a
|
|
17
|
+
// handled action failure into an unhandled one, and there is nowhere left
|
|
18
|
+
// to escalate to — the thing that would have reported it is what failed.
|
|
19
|
+
}
|
|
20
|
+
catch (reporterErr) {
|
|
21
|
+
void normalizeError(reporterErr);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Test/introspection helper — true once a reporter has been installed. */
|
|
25
|
+
export function hasActionErrorReporter() {
|
|
26
|
+
return reporter !== null;
|
|
27
|
+
}
|
|
@@ -36,6 +36,16 @@ export type ActionResult<T> = {
|
|
|
36
36
|
* ```
|
|
37
37
|
*
|
|
38
38
|
* `wrapAction` itself never throws. Callers can rely on the returned envelope.
|
|
39
|
+
*
|
|
40
|
+
* 🛑 BECAUSE it never throws, it is also the ONLY thing that will ever see the
|
|
41
|
+
* failure. Next.js calls `onRequestError` only for errors that ESCAPE a server
|
|
42
|
+
* action; converting the throw into a return value means that hook never fires.
|
|
43
|
+
* This catch used to be `void normalizeError(err)` — a pure classifier whose
|
|
44
|
+
* result is discarded — and then a return: no log, no store, no rethrow. All
|
|
45
|
+
* 222 server actions in this codebase (68 in appkit, 154 in the consumer) were
|
|
46
|
+
* therefore completely unobserved, which is why the bid failure this was
|
|
47
|
+
* written for had to be reported from a screenshot. Anything added here must
|
|
48
|
+
* keep recording; silence is the bug.
|
|
39
49
|
*/
|
|
40
50
|
export declare function wrapAction<T>(fn: () => Promise<T>): Promise<ActionResult<T>>;
|
|
41
51
|
/** Type guard for ActionResult success arm. */
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { normalizeError } from "../../../errors/normalize";
|
|
2
2
|
import { mapToHttpError } from "../../../errors/error-mapping";
|
|
3
|
+
import { describeCauseChain } from "../../../errors/describe-cause";
|
|
4
|
+
import { reportActionError } from "./action-error-reporter";
|
|
3
5
|
/**
|
|
4
6
|
* Wrap a server-action body so any thrown error is converted into the
|
|
5
7
|
* standard `{ ok: false, code, error, issues? }` envelope via `mapToHttpError`.
|
|
@@ -16,6 +18,16 @@ import { mapToHttpError } from "../../../errors/error-mapping";
|
|
|
16
18
|
* ```
|
|
17
19
|
*
|
|
18
20
|
* `wrapAction` itself never throws. Callers can rely on the returned envelope.
|
|
21
|
+
*
|
|
22
|
+
* 🛑 BECAUSE it never throws, it is also the ONLY thing that will ever see the
|
|
23
|
+
* failure. Next.js calls `onRequestError` only for errors that ESCAPE a server
|
|
24
|
+
* action; converting the throw into a return value means that hook never fires.
|
|
25
|
+
* This catch used to be `void normalizeError(err)` — a pure classifier whose
|
|
26
|
+
* result is discarded — and then a return: no log, no store, no rethrow. All
|
|
27
|
+
* 222 server actions in this codebase (68 in appkit, 154 in the consumer) were
|
|
28
|
+
* therefore completely unobserved, which is why the bid failure this was
|
|
29
|
+
* written for had to be reported from a screenshot. Anything added here must
|
|
30
|
+
* keep recording; silence is the bug.
|
|
19
31
|
*/
|
|
20
32
|
export async function wrapAction(fn) {
|
|
21
33
|
try {
|
|
@@ -27,6 +39,20 @@ export async function wrapAction(fn) {
|
|
|
27
39
|
const mapped = mapToHttpError(err, {
|
|
28
40
|
isProduction: process.env.NODE_ENV === "production",
|
|
29
41
|
});
|
|
42
|
+
// Record 5xx and datastore-level failures. 4xx business outcomes ("bid too
|
|
43
|
+
// low", "coupon expired") are the action working correctly and would drown
|
|
44
|
+
// the store — this is the same threshold createRouteHandler applies.
|
|
45
|
+
if (mapped.status >= 500 || mapped.infrastructure) {
|
|
46
|
+
// internalMessage ?? message — `message` is scrubbed for 5xx in
|
|
47
|
+
// production, and recording the scrubbed copy would defeat the point.
|
|
48
|
+
const cause = describeCauseChain(err);
|
|
49
|
+
reportActionError({
|
|
50
|
+
code: mapped.code,
|
|
51
|
+
message: mapped.internalMessage ?? mapped.message,
|
|
52
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
53
|
+
...(cause ? { cause } : {}),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
30
56
|
return {
|
|
31
57
|
ok: false,
|
|
32
58
|
code: mapped.code,
|
|
@@ -16,12 +16,14 @@
|
|
|
16
16
|
export interface SurfaceErrorOptions {
|
|
17
17
|
showToast: (message: string, variant?: "error" | "warning" | "info" | "success" | "loading") => void;
|
|
18
18
|
setFieldError?: (field: string, message: string) => void;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* next-intl style translator. When undefined the chain degrades to
|
|
21
|
+
* `fallbackMessage` and then to generic copy — NEVER to `err.message`.
|
|
22
|
+
*/
|
|
20
23
|
translate?: (key: string) => string;
|
|
21
24
|
/**
|
|
22
|
-
* Curated copy
|
|
23
|
-
*
|
|
24
|
-
* server string WITHOUT adding a second toast of its own.
|
|
25
|
+
* Curated copy used when the code resolves to no translation. Must be
|
|
26
|
+
* authored text; never a value derived from an error object.
|
|
25
27
|
*
|
|
26
28
|
* Never overrides field-level `issues` — those are more specific than any
|
|
27
29
|
* caller-supplied sentence.
|
|
@@ -2,7 +2,8 @@ import { isApiError } from "./ApiError";
|
|
|
2
2
|
import { ApiClientError } from "../../http/ApiClient";
|
|
3
3
|
import { applyZodIssues, hasAttachableIssue } from "../../ui/forms/apply-zod-issues";
|
|
4
4
|
import { normalizeError } from "../../errors/normalize";
|
|
5
|
-
import { getErrorDisplay } from "../../errors/error-display-map";
|
|
5
|
+
import { getErrorDisplay, toUserMessage } from "../../errors/error-display-map";
|
|
6
|
+
import { HTTP_ERROR_CODES } from "../../errors/error-mapping";
|
|
6
7
|
/** True for `ApiError` instances and for `ApiClientError` (the lower-level
|
|
7
8
|
* error `ApiClient.request()` throws) when it carries a stable `code` —
|
|
8
9
|
* both shapes route through the same toast-vs-inline-field-error logic
|
|
@@ -39,7 +40,13 @@ export function surfaceError(err, opts) {
|
|
|
39
40
|
}
|
|
40
41
|
if (hasStableErrorCode(err)) {
|
|
41
42
|
const display = getErrorDisplay(err.code);
|
|
42
|
-
|
|
43
|
+
// `?? err.message` used to terminate this chain. With no `translate` and no
|
|
44
|
+
// `fallbackMessage` — the shape most call sites use — that surfaced the
|
|
45
|
+
// SERVER's own message, which for a 5xx is its internals. `toUserMessage`
|
|
46
|
+
// ends at `errors.codes.UNKNOWN` instead.
|
|
47
|
+
const message = toUserMessage(err.code, translate, {
|
|
48
|
+
fallback: fallbackMessage,
|
|
49
|
+
});
|
|
43
50
|
if (display.field && setFieldError) {
|
|
44
51
|
setFieldError(display.field, message);
|
|
45
52
|
return;
|
|
@@ -47,16 +54,20 @@ export function surfaceError(err, opts) {
|
|
|
47
54
|
showToast(message, "error");
|
|
48
55
|
return;
|
|
49
56
|
}
|
|
50
|
-
// Non-ApiError —
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
// Non-ApiError — a programming error or unhandled exception.
|
|
58
|
+
//
|
|
59
|
+
// The raw text is REPORTED (below) but never SHOWN. That split is the point:
|
|
60
|
+
// this branch is where a raw Node/DOM string arrives, and it used to be
|
|
61
|
+
// toasted verbatim whenever no translator was supplied.
|
|
62
|
+
const internalMessage = err instanceof Error ? err.message : String(err ?? "unknown error");
|
|
63
|
+
showToast(toUserMessage(HTTP_ERROR_CODES.CLIENT_UNHANDLED, translate, {
|
|
64
|
+
fallback: fallbackMessage,
|
|
65
|
+
}), "error");
|
|
55
66
|
if (report) {
|
|
56
67
|
try {
|
|
57
68
|
report({
|
|
58
69
|
code: "CLIENT_UNHANDLED",
|
|
59
|
-
message,
|
|
70
|
+
message: internalMessage,
|
|
60
71
|
stack: err instanceof Error ? err.stack : undefined,
|
|
61
72
|
});
|
|
62
73
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface RealtimeToken {
|
|
2
|
+
customToken: string;
|
|
3
|
+
expiresAt: number;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Ensure the shared realtime app is signed in for `scope`, and take a lease.
|
|
7
|
+
*
|
|
8
|
+
* @param scope an identifier for the claim set (e.g. `"messages"`,
|
|
9
|
+
* `"chat:<id>"`). Signing in again for the SAME scope while a
|
|
10
|
+
* valid token is held is a no-op.
|
|
11
|
+
* @param getToken fetches a fresh custom token. Called only when needed.
|
|
12
|
+
* @returns a release function. **Always call it** — the session is only torn
|
|
13
|
+
* down when every lease is released.
|
|
14
|
+
*/
|
|
15
|
+
export declare function acquireRealtimeSession(scope: string, getToken: () => Promise<RealtimeToken>): Promise<() => void>;
|
|
16
|
+
/**
|
|
17
|
+
* Take a lease WITHOUT signing in — for a consumer that manages its own
|
|
18
|
+
* sign-in but must still participate in the reference count.
|
|
19
|
+
*
|
|
20
|
+
* `useRealtimeEvent` is the case: its token carries a job-scoped `bulkJobId`
|
|
21
|
+
* claim it receives from the caller, so it cannot use `acquireRealtimeSession`'s
|
|
22
|
+
* fetcher. It still must not be able to sign the shared app out from under
|
|
23
|
+
* another channel, which is precisely what its `cleanup()` used to do.
|
|
24
|
+
*/
|
|
25
|
+
export declare function acquireExternalRealtimeLease(): () => void;
|
|
26
|
+
/** Test/diagnostic only. */
|
|
27
|
+
export declare function activeRealtimeLeases(): number;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { normalizeError } from "../errors/normalize";
|
|
2
|
+
import { getClientRealtimeProvider } from "./client-realtime";
|
|
3
|
+
/**
|
|
4
|
+
* Shared, REFERENCE-COUNTED sign-in for the one client realtime app.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS — two distinct bugs, one cause.
|
|
7
|
+
*
|
|
8
|
+
* 1. **Nobody signed in for messaging.** `useConversation` and
|
|
9
|
+
* `useConversations` subscribed to `chats/...` without ever calling
|
|
10
|
+
* `signInWithToken`, so `auth == null` and both RTDB rules evaluated false.
|
|
11
|
+
* Every buyer↔seller live update was permission-denied, and both hooks
|
|
12
|
+
* swallowed it (one into `setIsConnected(false)`, one into a literal
|
|
13
|
+
* `// ignore`), so the channel looked implemented and was inert. Only
|
|
14
|
+
* `useChat` and `useRealtimeEvent` ever authenticated.
|
|
15
|
+
*
|
|
16
|
+
* 2. **Signing out is a SHARED action.** `FirebaseClientRealtimeProvider` is
|
|
17
|
+
* registered once, as a singleton on the `"letitrip-realtime"` app. So
|
|
18
|
+
* `useRealtimeEvent.cleanup()` calling `provider.signOut()` when a bulk job
|
|
19
|
+
* finished tore the session out from under an admin chat that was still
|
|
20
|
+
* open — and, in reverse, `useChat`'s token (carrying `chatIds`) overwrote a
|
|
21
|
+
* bulk token (carrying `bulkJobId`), killing an in-flight job subscription.
|
|
22
|
+
* Whoever finished last broke whoever was still working.
|
|
23
|
+
*
|
|
24
|
+
* The fix for (2) is not "sign out more carefully" — it is that **no individual
|
|
25
|
+
* consumer may decide to sign out at all**. Each acquires a lease and releases
|
|
26
|
+
* it; the session ends only when the last lease is gone.
|
|
27
|
+
*
|
|
28
|
+
* Tokens differ per channel (different claims), so `acquire` takes a fetcher and
|
|
29
|
+
* re-signs when the token it holds is for a different scope or has expired.
|
|
30
|
+
*/
|
|
31
|
+
/** Refresh a little early — a token that expires mid-subscription is a silent drop. */
|
|
32
|
+
const EXPIRY_SKEW_MS = 60000;
|
|
33
|
+
let leases = 0;
|
|
34
|
+
let currentScope = null;
|
|
35
|
+
let expiresAt = 0;
|
|
36
|
+
let inFlight = null;
|
|
37
|
+
/**
|
|
38
|
+
* Ensure the shared realtime app is signed in for `scope`, and take a lease.
|
|
39
|
+
*
|
|
40
|
+
* @param scope an identifier for the claim set (e.g. `"messages"`,
|
|
41
|
+
* `"chat:<id>"`). Signing in again for the SAME scope while a
|
|
42
|
+
* valid token is held is a no-op.
|
|
43
|
+
* @param getToken fetches a fresh custom token. Called only when needed.
|
|
44
|
+
* @returns a release function. **Always call it** — the session is only torn
|
|
45
|
+
* down when every lease is released.
|
|
46
|
+
*/
|
|
47
|
+
export async function acquireRealtimeSession(scope, getToken) {
|
|
48
|
+
const lease = { scope };
|
|
49
|
+
leases++;
|
|
50
|
+
const needsSignIn = currentScope !== lease.scope || Date.now() > expiresAt - EXPIRY_SKEW_MS;
|
|
51
|
+
if (needsSignIn) {
|
|
52
|
+
// Collapse concurrent acquires — several hooks mount in the same tick and
|
|
53
|
+
// would otherwise each fetch a token and race to sign in.
|
|
54
|
+
inFlight ?? (inFlight = (async () => {
|
|
55
|
+
try {
|
|
56
|
+
const token = await getToken();
|
|
57
|
+
await getClientRealtimeProvider().signInWithToken(token.customToken);
|
|
58
|
+
currentScope = lease.scope;
|
|
59
|
+
expiresAt = token.expiresAt;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
inFlight = null;
|
|
63
|
+
}
|
|
64
|
+
})());
|
|
65
|
+
await inFlight;
|
|
66
|
+
}
|
|
67
|
+
let released = false;
|
|
68
|
+
return () => {
|
|
69
|
+
if (released)
|
|
70
|
+
return; // React StrictMode double-invokes cleanups
|
|
71
|
+
released = true;
|
|
72
|
+
leases = Math.max(0, leases - 1);
|
|
73
|
+
if (leases > 0)
|
|
74
|
+
return;
|
|
75
|
+
currentScope = null;
|
|
76
|
+
expiresAt = 0;
|
|
77
|
+
getClientRealtimeProvider()
|
|
78
|
+
.signOut()
|
|
79
|
+
.catch((err) => {
|
|
80
|
+
void normalizeError(err);
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Take a lease WITHOUT signing in — for a consumer that manages its own
|
|
86
|
+
* sign-in but must still participate in the reference count.
|
|
87
|
+
*
|
|
88
|
+
* `useRealtimeEvent` is the case: its token carries a job-scoped `bulkJobId`
|
|
89
|
+
* claim it receives from the caller, so it cannot use `acquireRealtimeSession`'s
|
|
90
|
+
* fetcher. It still must not be able to sign the shared app out from under
|
|
91
|
+
* another channel, which is precisely what its `cleanup()` used to do.
|
|
92
|
+
*/
|
|
93
|
+
export function acquireExternalRealtimeLease() {
|
|
94
|
+
leases++;
|
|
95
|
+
// Its own sign-in has replaced whatever scope was active; forget the cached
|
|
96
|
+
// one so the next acquire() re-authenticates rather than trusting a token
|
|
97
|
+
// that is no longer installed.
|
|
98
|
+
currentScope = null;
|
|
99
|
+
expiresAt = 0;
|
|
100
|
+
let released = false;
|
|
101
|
+
return () => {
|
|
102
|
+
if (released)
|
|
103
|
+
return;
|
|
104
|
+
released = true;
|
|
105
|
+
leases = Math.max(0, leases - 1);
|
|
106
|
+
if (leases > 0)
|
|
107
|
+
return;
|
|
108
|
+
getClientRealtimeProvider()
|
|
109
|
+
.signOut()
|
|
110
|
+
.catch((err) => {
|
|
111
|
+
void normalizeError(err);
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Test/diagnostic only. */
|
|
116
|
+
export function activeRealtimeLeases() {
|
|
117
|
+
return leases;
|
|
118
|
+
}
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { normalizeError } from "../errors/normalize";
|
|
2
|
-
|
|
2
|
+
// Static import, deliberately. This used to be
|
|
3
|
+
// `(module as any).require("../providers/db-firebase")` — the bundler-evasion
|
|
4
|
+
// idiom used correctly elsewhere in appkit (admin.ts's firebase-admin loaders),
|
|
5
|
+
// but every OTHER use passes a BARE specifier, which Node resolves from
|
|
6
|
+
// node_modules at any depth. A RELATIVE one is resolved against the emitted
|
|
7
|
+
// chunk, not this source file, so in a bundled Lambda it became
|
|
8
|
+
// `.next/server/chunks/providers/db-firebase` → MODULE_NOT_FOUND, and every
|
|
9
|
+
// runBatch/runTransaction threw before touching Firestore.
|
|
10
|
+
//
|
|
11
|
+
// The lazy form also bought nothing: the 19 repository imports below already
|
|
12
|
+
// pull this module into the graph statically (bid.repository.ts et al), and
|
|
13
|
+
// db-firebase reaches firebase-admin only through `import type` plus its own
|
|
14
|
+
// bare-specifier runtime requires. See CLAUDE.md Root Cause #24.
|
|
15
|
+
import { getAdminDb } from "../providers/db-firebase";
|
|
3
16
|
import { DatabaseError } from "../errors";
|
|
4
|
-
import { serverLogger } from "../monitoring";
|
|
5
17
|
import { userRepository, tokenRepository, sessionRepository, } from "../features/auth/repository";
|
|
6
18
|
import { addressesRepository } from "../features/addresses/repository/addresses.repository";
|
|
7
19
|
import { cartRepository } from "../features/cart/repository/cart.repository";
|
|
@@ -81,32 +93,28 @@ export class UnitOfWork {
|
|
|
81
93
|
return notificationRepository;
|
|
82
94
|
}
|
|
83
95
|
async runTransaction(fn) {
|
|
84
|
-
serverLogger.debug("[UnitOfWork] Starting transaction");
|
|
85
96
|
try {
|
|
86
|
-
|
|
87
|
-
serverLogger.debug("[UnitOfWork] Transaction committed successfully");
|
|
88
|
-
return result;
|
|
97
|
+
return await this.db.runTransaction(fn);
|
|
89
98
|
}
|
|
90
99
|
catch (error) {
|
|
91
100
|
void normalizeError(error);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
// Interpolate the underlying message. Without it the persisted
|
|
102
|
+
// serverErrors row reads only "Transaction failed" and the real cause
|
|
103
|
+
// survives nowhere the store can see — `cause` carries the stack, but
|
|
104
|
+
// the message is what the admin list actually renders.
|
|
105
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
106
|
+
throw new DatabaseError(`Transaction failed: ${detail}`, error);
|
|
96
107
|
}
|
|
97
108
|
}
|
|
98
109
|
async runBatch(fn) {
|
|
99
|
-
serverLogger.debug("[UnitOfWork] Starting batch write");
|
|
100
110
|
try {
|
|
101
111
|
const batch = this.db.batch();
|
|
102
112
|
await fn(batch);
|
|
103
113
|
await batch.commit();
|
|
104
|
-
serverLogger.debug("[UnitOfWork] Batch write committed successfully");
|
|
105
114
|
}
|
|
106
115
|
catch (error) {
|
|
107
116
|
void normalizeError(error);
|
|
108
117
|
const detail = error instanceof Error ? error.message : String(error);
|
|
109
|
-
serverLogger.error("[UnitOfWork] Batch write failed", { error: detail });
|
|
110
118
|
throw new DatabaseError(`Batch write failed: ${detail}`, error);
|
|
111
119
|
}
|
|
112
120
|
}
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
export class AppError extends Error {
|
|
2
2
|
constructor(statusCode, message, code, data) {
|
|
3
|
-
|
|
3
|
+
// Forward `data` as the standard `cause` when it is itself an Error.
|
|
4
|
+
//
|
|
5
|
+
// Every AppError in this codebase is a WRAP — `new DatabaseError("Batch
|
|
6
|
+
// write failed: …", originalError)` — and until 2026-08-28 the original was
|
|
7
|
+
// bound only to `data`, never to `cause`. Nothing that walks the cause chain
|
|
8
|
+
// (util.inspect, the Next error overlay, any APM, our own serverErrors
|
|
9
|
+
// recorder) could reach it, so the underlying fault was retained in memory
|
|
10
|
+
// and dropped by every consumer.
|
|
11
|
+
//
|
|
12
|
+
// `captureStackTrace` below additionally rebases `.stack` onto this
|
|
13
|
+
// constructor, so the wrapper's own stack starts at the catch block that
|
|
14
|
+
// wrapped it — the frames that named the real fault live ONLY on the cause.
|
|
15
|
+
// That is why preserving it is load-bearing rather than cosmetic.
|
|
16
|
+
super(message, data instanceof Error ? { cause: data } : undefined);
|
|
4
17
|
this.statusCode = statusCode;
|
|
5
18
|
this.message = message;
|
|
6
19
|
this.code = code;
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { AppError } from "./base-error";
|
|
2
|
+
import { ERROR_CODES } from "./error-codes";
|
|
2
3
|
export class DatabaseError extends AppError {
|
|
3
4
|
constructor(message, data) {
|
|
4
|
-
|
|
5
|
+
// `ERROR_CODES.DB_OPERATION_FAILED` ("DB_001"), NOT the literal
|
|
6
|
+
// "DATABASE_ERROR" this used to emit.
|
|
7
|
+
//
|
|
8
|
+
// That literal had exactly one reference in the entire repo — this line.
|
|
9
|
+
// It was absent from ERROR_CODES, HTTP_ERROR_CODES, ERROR_DISPLAY_MAP and
|
|
10
|
+
// messages/en.json, so all ~193 DatabaseError throw sites produced a code
|
|
11
|
+
// no lookup could resolve and every one of them fell through to the raw
|
|
12
|
+
// internal message. "DB_001" already has both a display-map entry and the
|
|
13
|
+
// translation "A database error occurred. Please try again".
|
|
14
|
+
super(500, message, ERROR_CODES.DB_OPERATION_FAILED, data);
|
|
5
15
|
}
|
|
6
16
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { normalizeError } from "./normalize";
|
|
2
|
+
/**
|
|
3
|
+
* Flatten an `Error.cause` chain into a single persistable string.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* Every `AppError` in this codebase is a WRAP — `new DatabaseError("Batch
|
|
7
|
+
* write failed: …", originalError)` — and `AppError` calls
|
|
8
|
+
* `Error.captureStackTrace(this, this.constructor)`, which rebases `.stack`
|
|
9
|
+
* onto the wrapping constructor. So the wrapper's own stack starts at the
|
|
10
|
+
* catch block that wrapped it, and the frames that actually name the fault
|
|
11
|
+
* live only on the cause.
|
|
12
|
+
*
|
|
13
|
+
* Nothing used to read them. `JSON.stringify(new Error("boom"))` is `"{}"`,
|
|
14
|
+
* so the one logger path that tried (`buildServerMeta`) serialised the cause
|
|
15
|
+
* to an empty object, and the serverErrors schema had no field for it at all.
|
|
16
|
+
* The only reason the original message was ever readable was one call site
|
|
17
|
+
* interpolating it into its own message by hand.
|
|
18
|
+
*
|
|
19
|
+
* Depth-capped and byte-capped: a cause chain is untrusted in length, and this
|
|
20
|
+
* value is persisted into a document that must stay inside the 4 KB stack
|
|
21
|
+
* budget documented on `ServerErrorDocument`.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_DEPTH = 5;
|
|
24
|
+
const MAX_BYTES = 4096;
|
|
25
|
+
/** `cause` is only present on the ES2022 Error shape; read it structurally. */
|
|
26
|
+
function causeOf(value) {
|
|
27
|
+
return value.cause;
|
|
28
|
+
}
|
|
29
|
+
function messageOf(err) {
|
|
30
|
+
if (err instanceof Error)
|
|
31
|
+
return `${err.name}: ${err.message}`;
|
|
32
|
+
if (typeof err === "string")
|
|
33
|
+
return err;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.stringify(err) ?? String(err);
|
|
36
|
+
// JSON.stringify throws only on a circular/BigInt value; String() always
|
|
37
|
+
// succeeds. This runs inside the error path, where throwing would lose the
|
|
38
|
+
// original error we were trying to describe.
|
|
39
|
+
}
|
|
40
|
+
catch (stringifyErr) {
|
|
41
|
+
void normalizeError(stringifyErr);
|
|
42
|
+
return String(err);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* @returns the flattened chain, or `undefined` when there is no cause — so the
|
|
47
|
+
* caller can omit the Firestore field entirely rather than writing `""`.
|
|
48
|
+
*/
|
|
49
|
+
export function describeCauseChain(err) {
|
|
50
|
+
if (!(err instanceof Error))
|
|
51
|
+
return undefined;
|
|
52
|
+
const parts = [];
|
|
53
|
+
const seen = new Set([err]);
|
|
54
|
+
let current = causeOf(err);
|
|
55
|
+
let depth = 0;
|
|
56
|
+
while (current !== undefined && current !== null && depth < MAX_DEPTH) {
|
|
57
|
+
// A cause cycle is rare but cheap to guard, and an infinite loop here would
|
|
58
|
+
// hang the error path — the one place that must never make things worse.
|
|
59
|
+
if (seen.has(current)) {
|
|
60
|
+
parts.push("caused by: <circular>");
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
seen.add(current);
|
|
64
|
+
const head = messageOf(current);
|
|
65
|
+
const stack = current instanceof Error ? current.stack : undefined;
|
|
66
|
+
// Prefer the stack (it contains the message as its first line); fall back
|
|
67
|
+
// to the message alone for non-Error causes.
|
|
68
|
+
parts.push(`caused by: ${stack ?? head}`);
|
|
69
|
+
current = typeof current === "object" ? causeOf(current) : undefined;
|
|
70
|
+
depth++;
|
|
71
|
+
}
|
|
72
|
+
if (parts.length === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
const joined = parts.join("\n\n");
|
|
75
|
+
return joined.length <= MAX_BYTES ? joined : joined.slice(0, MAX_BYTES);
|
|
76
|
+
}
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
* - `field`: when present + a FormShellContext is in scope, the client wrapper
|
|
4
4
|
* routes the error inline as a field error instead of a toast.
|
|
5
5
|
* - `messageKey`: a `next-intl` key under `errors.codes.*` resolved at render time.
|
|
6
|
-
* Falls back to `errors.codes.UNKNOWN
|
|
6
|
+
* Falls back to `errors.codes.UNKNOWN`, and then to `GENERIC_USER_MESSAGE`.
|
|
7
|
+
*
|
|
8
|
+
* 🛑 The chain terminates in a CONSTANT. It used to end "…then to the raw
|
|
9
|
+
* `Error.message` if absent", and that tail is what printed a Node require
|
|
10
|
+
* stack into the bid modal. Resolve codes through `toUserMessage` below; never
|
|
11
|
+
* re-add a raw-message fallback.
|
|
7
12
|
*/
|
|
8
13
|
export interface ErrorDisplayEntry {
|
|
9
14
|
field?: string;
|
|
@@ -12,3 +17,29 @@ export interface ErrorDisplayEntry {
|
|
|
12
17
|
export declare const ERROR_DISPLAY_MAP: Record<string, ErrorDisplayEntry>;
|
|
13
18
|
/** Look up display metadata; returns a generic entry when the code is unknown. */
|
|
14
19
|
export declare function getErrorDisplay(code: string): ErrorDisplayEntry;
|
|
20
|
+
/** Last-resort copy when no translator is available. Never a server string. */
|
|
21
|
+
export declare const GENERIC_USER_MESSAGE = "Something went wrong. Please try again.";
|
|
22
|
+
/**
|
|
23
|
+
* The user-facing message for an error code. **Never returns server text.**
|
|
24
|
+
*
|
|
25
|
+
* 🛑 This is the whole point: every previous way of doing this ended in a raw
|
|
26
|
+
* fallback. `ERROR_DISPLAY_MAP[code] ?? error` — the pattern CLAUDE.md Rule #9
|
|
27
|
+
* §6 itself documented — falls through to the server's own message, and that
|
|
28
|
+
* is how a Node `MODULE_NOT_FOUND`, complete with
|
|
29
|
+
* `/var/task/.next/server/chunks/...` and its full require stack, was rendered
|
|
30
|
+
* as copy inside the "Place your bid" modal. `surfaceError` had the same tail.
|
|
31
|
+
* There was no shared helper that did this correctly, which is why ~60 call
|
|
32
|
+
* sites each invented `err.message`.
|
|
33
|
+
*
|
|
34
|
+
* The chain is: code -> messageKey -> translation -> `errors.codes.UNKNOWN` ->
|
|
35
|
+
* `GENERIC_USER_MESSAGE`. It terminates in a constant, deliberately.
|
|
36
|
+
*
|
|
37
|
+
* @param code stable error code from an `ActionResult` / error envelope
|
|
38
|
+
* @param t a next-intl translator; omitted, the generic string is used
|
|
39
|
+
* @param options `fallback` overrides the generic string with FEATURE-SPECIFIC
|
|
40
|
+
* copy you have authored ("Could not place your bid"). It must
|
|
41
|
+
* never be a value derived from an error object.
|
|
42
|
+
*/
|
|
43
|
+
export declare function toUserMessage(code: string | undefined, t?: (key: string) => string, options?: {
|
|
44
|
+
fallback?: string;
|
|
45
|
+
}): string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeError } from "./normalize";
|
|
1
2
|
import { ERROR_CODES, BID_ERROR_CODES, OFFER_ERROR_CODES } from "./error-codes";
|
|
2
3
|
import { HTTP_ERROR_CODES } from "./error-mapping";
|
|
3
4
|
const k = (code) => `errors.codes.${code}`;
|
|
@@ -240,3 +241,48 @@ export const ERROR_DISPLAY_MAP = {
|
|
|
240
241
|
export function getErrorDisplay(code) {
|
|
241
242
|
return ERROR_DISPLAY_MAP[code] ?? { messageKey: "errors.codes.UNKNOWN" };
|
|
242
243
|
}
|
|
244
|
+
/** Last-resort copy when no translator is available. Never a server string. */
|
|
245
|
+
export const GENERIC_USER_MESSAGE = "Something went wrong. Please try again.";
|
|
246
|
+
/**
|
|
247
|
+
* The user-facing message for an error code. **Never returns server text.**
|
|
248
|
+
*
|
|
249
|
+
* 🛑 This is the whole point: every previous way of doing this ended in a raw
|
|
250
|
+
* fallback. `ERROR_DISPLAY_MAP[code] ?? error` — the pattern CLAUDE.md Rule #9
|
|
251
|
+
* §6 itself documented — falls through to the server's own message, and that
|
|
252
|
+
* is how a Node `MODULE_NOT_FOUND`, complete with
|
|
253
|
+
* `/var/task/.next/server/chunks/...` and its full require stack, was rendered
|
|
254
|
+
* as copy inside the "Place your bid" modal. `surfaceError` had the same tail.
|
|
255
|
+
* There was no shared helper that did this correctly, which is why ~60 call
|
|
256
|
+
* sites each invented `err.message`.
|
|
257
|
+
*
|
|
258
|
+
* The chain is: code -> messageKey -> translation -> `errors.codes.UNKNOWN` ->
|
|
259
|
+
* `GENERIC_USER_MESSAGE`. It terminates in a constant, deliberately.
|
|
260
|
+
*
|
|
261
|
+
* @param code stable error code from an `ActionResult` / error envelope
|
|
262
|
+
* @param t a next-intl translator; omitted, the generic string is used
|
|
263
|
+
* @param options `fallback` overrides the generic string with FEATURE-SPECIFIC
|
|
264
|
+
* copy you have authored ("Could not place your bid"). It must
|
|
265
|
+
* never be a value derived from an error object.
|
|
266
|
+
*/
|
|
267
|
+
export function toUserMessage(code, t, options) {
|
|
268
|
+
const generic = options?.fallback ?? GENERIC_USER_MESSAGE;
|
|
269
|
+
if (!code)
|
|
270
|
+
return generic;
|
|
271
|
+
const { messageKey } = getErrorDisplay(code);
|
|
272
|
+
if (!t)
|
|
273
|
+
return generic;
|
|
274
|
+
try {
|
|
275
|
+
const translated = t(messageKey);
|
|
276
|
+
// next-intl returns the key itself (or throws) when a message is missing;
|
|
277
|
+
// either way that is not something to show a user.
|
|
278
|
+
if (translated && translated !== messageKey)
|
|
279
|
+
return translated;
|
|
280
|
+
// next-intl throws MISSING_MESSAGE for an absent key. This function's whole
|
|
281
|
+
// contract is to degrade to generic copy rather than let ANY internal
|
|
282
|
+
// string — including an i18n internal — reach the user.
|
|
283
|
+
}
|
|
284
|
+
catch (translateErr) {
|
|
285
|
+
void normalizeError(translateErr);
|
|
286
|
+
}
|
|
287
|
+
return generic;
|
|
288
|
+
}
|