@payez/next-mvp 4.8.0 → 4.8.2
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/POST-INSTALL.md +408 -408
- package/README.md +859 -859
- package/dist/components/account/MobileNavDrawer.js +18 -18
- package/dist/components/account/UserAvatarMenu.js +1 -1
- package/dist/components/admin/AlertSettingsTab.js +40 -40
- package/dist/components/admin/LoggingSettingsTab.js +28 -28
- package/dist/lib/session-store.js +21 -21
- package/dist/lib/startup-init.js +19 -19
- package/dist/middleware/create-middleware.js +6 -5
- package/dist/pages/login/page.js +28 -28
- package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
- package/dist/vibe/sessions.js +28 -3
- package/package.json +2 -2
- package/scripts/check-publish-eol.mjs +33 -15
- package/src/api/auth-handler.ts +550 -550
- package/src/api/index.ts +17 -17
- package/src/api-handlers/account/change-password.ts +170 -170
- package/src/api-handlers/account/masked-info.ts +46 -46
- package/src/api-handlers/account/profile.ts +99 -99
- package/src/api-handlers/account/recovery/forward.ts +117 -117
- package/src/api-handlers/account/recovery/initiate.ts +59 -59
- package/src/api-handlers/account/recovery/send-code.ts +21 -21
- package/src/api-handlers/account/recovery/verify-code.ts +20 -20
- package/src/api-handlers/account/reset-password.ts +87 -87
- package/src/api-handlers/account/send-code.ts +70 -70
- package/src/api-handlers/account/update-phone.ts +84 -84
- package/src/api-handlers/account/validate-password.ts +139 -139
- package/src/api-handlers/account/verify-email.ts +134 -134
- package/src/api-handlers/account/verify-sms.ts +134 -134
- package/src/api-handlers/admin/analytics.ts +452 -452
- package/src/api-handlers/admin/audit.ts +232 -232
- package/src/api-handlers/admin/index.ts +63 -63
- package/src/api-handlers/admin/redis-sessions.ts +259 -259
- package/src/api-handlers/admin/sessions.ts +327 -327
- package/src/api-handlers/admin/site-logs.ts +388 -388
- package/src/api-handlers/admin/stats.ts +260 -260
- package/src/api-handlers/admin/users.ts +251 -251
- package/src/api-handlers/admin/vibe-data.ts +333 -333
- package/src/api-handlers/anon/preferences.ts +123 -123
- package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
- package/src/api-handlers/auth/credential-login.ts +948 -948
- package/src/api-handlers/auth/jwks.ts +31 -31
- package/src/api-handlers/auth/login.ts +252 -252
- package/src/api-handlers/auth/passkey-signin.ts +116 -116
- package/src/api-handlers/auth/refresh.ts +829 -829
- package/src/api-handlers/auth/signout.ts +201 -201
- package/src/api-handlers/auth/status.ts +23 -23
- package/src/api-handlers/auth/update-session.ts +144 -144
- package/src/api-handlers/auth/validate.ts +45 -45
- package/src/api-handlers/auth/verify-code.ts +116 -116
- package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
- package/src/api-handlers/mfa/backup-codes.ts +42 -42
- package/src/api-handlers/mfa/methods.ts +47 -47
- package/src/api-handlers/mfa/totp-confirm.ts +43 -43
- package/src/api-handlers/mfa/totp-enroll.ts +43 -43
- package/src/api-handlers/mfa/totp-verify.ts +172 -172
- package/src/api-handlers/oauth/resume.ts +182 -182
- package/src/api-handlers/onboarding/send-email-code.ts +18 -18
- package/src/api-handlers/onboarding/set-password.ts +21 -21
- package/src/api-handlers/onboarding/verify-code.ts +20 -20
- package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
- package/src/api-handlers/passkeys/list.ts +32 -32
- package/src/api-handlers/passkeys/manage.ts +55 -55
- package/src/api-handlers/passkeys/register-options.ts +43 -43
- package/src/api-handlers/passkeys/register-verify.ts +42 -42
- package/src/api-handlers/session/refresh-viability.ts +56 -56
- package/src/api-handlers/session/viability.ts +238 -238
- package/src/api-handlers/test/force-expire.ts +167 -167
- package/src/auth/auth-decision.ts +292 -292
- package/src/auth/better-auth.ts +1688 -1688
- package/src/auth/idp-oauth.ts +310 -310
- package/src/auth/password-reset-bridge.ts +98 -98
- package/src/auth/route-config.ts +236 -236
- package/src/auth/safe-callback.ts +199 -199
- package/src/auth/session-cookie-clear.ts +30 -30
- package/src/auth/session-reset-link.ts +159 -159
- package/src/auth/session-response-sanitizer.ts +151 -151
- package/src/auth/types/auth-types.ts +555 -555
- package/src/auth/types/index.ts +7 -7
- package/src/auth/unauthenticated-routes.ts +2 -2
- package/src/auth/utils/idp-client.ts +364 -364
- package/src/auth/utils/index.ts +6 -6
- package/src/auth/utils/token-utils.ts +243 -243
- package/src/client/AuthContext.tsx +192 -192
- package/src/client/better-auth-client.ts +70 -70
- package/src/client/fetch-with-auth.ts +47 -47
- package/src/client/fetchWithSession.ts +21 -21
- package/src/client/index.ts +13 -13
- package/src/client/useAnonSession.ts +131 -131
- package/src/components/SessionSync.tsx +138 -138
- package/src/components/SignalRHealthCheck.tsx +131 -131
- package/src/components/account/MobileNavDrawer.tsx +305 -305
- package/src/components/account/UserAvatarMenu.tsx +277 -277
- package/src/components/account/index.ts +13 -13
- package/src/components/admin/AlertSettingsTab.tsx +728 -728
- package/src/components/admin/AnalyticsTab.tsx +703 -703
- package/src/components/admin/DataBrowserTab.tsx +508 -508
- package/src/components/admin/LoggingSettingsTab.tsx +665 -665
- package/src/components/admin/SessionsTab.tsx +421 -421
- package/src/components/admin/StatsTab.tsx +379 -379
- package/src/components/admin/VibeAdminContext.tsx +87 -87
- package/src/components/admin/VibeAdminLayout.tsx +187 -187
- package/src/components/admin/index.ts +59 -59
- package/src/components/auth/CredentialSignInForm.tsx +240 -240
- package/src/components/auth/FederatedAuthSection.tsx +129 -129
- package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
- package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
- package/src/components/auth/PasswordResetBridge.tsx +30 -30
- package/src/components/auth/TraditionalAuthSection.tsx +103 -103
- package/src/components/recovery/CompleteStep.tsx +36 -36
- package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
- package/src/components/recovery/RequestedStep.tsx +30 -30
- package/src/components/recovery/SelectMethodStep.tsx +73 -73
- package/src/components/recovery/SetPasswordStep.tsx +118 -118
- package/src/components/recovery/VerifyCodeStep.tsx +90 -90
- package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
- package/src/components/reserved/ReservedStatusBox.tsx +118 -118
- package/src/components/ui/BetaBadge.tsx +58 -58
- package/src/components/ui/Footer.tsx +93 -93
- package/src/config/env.ts +55 -55
- package/src/config/logger.ts +62 -62
- package/src/config/logging-config.ts +82 -82
- package/src/config/unauthenticated-routes.ts +19 -19
- package/src/config/vibe-log-transport.ts +264 -264
- package/src/edge/internal-api-url.ts +65 -65
- package/src/edge/middleware.ts +42 -42
- package/src/hooks/useAuth.ts +117 -117
- package/src/hooks/useAuthSettings.ts +109 -109
- package/src/hooks/useAvailableProviders.ts +143 -143
- package/src/hooks/usePasswordValidation.ts +127 -127
- package/src/hooks/useProfile.ts +82 -82
- package/src/hooks/usePublicAuthSettings.ts +171 -171
- package/src/hooks/useSessionExpiration.ts +101 -101
- package/src/hooks/useViabilitySession.ts +337 -337
- package/src/index.ts +64 -64
- package/src/lib/anon-session.ts +213 -213
- package/src/lib/api-handler.ts +643 -643
- package/src/lib/app-slug.ts +228 -228
- package/src/lib/authorize-resume.ts +172 -172
- package/src/lib/bearer-fetch.ts +31 -31
- package/src/lib/client-key.ts +88 -88
- package/src/lib/code-step-copy.ts +83 -83
- package/src/lib/credential-sign-in-client.ts +101 -101
- package/src/lib/demo-mode.ts +18 -18
- package/src/lib/device-id.ts +67 -67
- package/src/lib/ensure-fresh-access-token.ts +509 -509
- package/src/lib/geolocation.ts +265 -265
- package/src/lib/idp-client-config.ts +1005 -1005
- package/src/lib/idp-fetch.ts +169 -169
- package/src/lib/idp-url-guard.ts +37 -37
- package/src/lib/internal-api.ts +171 -171
- package/src/lib/jwt-decode-client.ts +45 -45
- package/src/lib/jwt-decode.ts +82 -82
- package/src/lib/lock-window.ts +49 -49
- package/src/lib/login-error-message.ts +41 -41
- package/src/lib/never-in-response.ts +64 -64
- package/src/lib/nextauth-secret.ts +168 -168
- package/src/lib/onboarding-proxy.ts +57 -57
- package/src/lib/password-rules.ts +50 -50
- package/src/lib/rate-limit-service.ts +9 -9
- package/src/lib/redis.ts +105 -105
- package/src/lib/refresh-token-validator.ts +64 -64
- package/src/lib/roles.ts +177 -177
- package/src/lib/safe-error-response.ts +149 -149
- package/src/lib/secret-validation.ts +8 -8
- package/src/lib/session-cookie-shape.ts +185 -185
- package/src/lib/session-store.ts +1092 -1092
- package/src/lib/session.ts +34 -34
- package/src/lib/single-flight.ts +64 -64
- package/src/lib/site-logger.ts +245 -245
- package/src/lib/standardized-client-api.ts +901 -901
- package/src/lib/startup-init.ts +276 -276
- package/src/lib/test-aware-get-token.ts +41 -41
- package/src/lib/token-expiry.ts +40 -40
- package/src/lib/token-lifecycle.ts +546 -546
- package/src/lib/token-log-id.ts +69 -69
- package/src/lib/twofa-rejection.ts +91 -91
- package/src/lib/types/api-responses.ts +336 -336
- package/src/lib/user-agent-parser.ts +252 -252
- package/src/logging/api/admin-analytics.ts +51 -51
- package/src/logging/api/audit-log.ts +53 -53
- package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
- package/src/logging/components/AuditLogViewer.tsx +125 -125
- package/src/logging/components/ErrorMetricsCard.tsx +98 -98
- package/src/logging/components/HealthMetricsCard.tsx +70 -70
- package/src/logging/hooks/useAdminAnalytics.ts +22 -22
- package/src/logging/hooks/useAuditLog.ts +24 -24
- package/src/logging/hooks/useErrorMetrics.ts +40 -40
- package/src/logging/hooks/useHealthMetrics.ts +44 -44
- package/src/logging/index.ts +18 -18
- package/src/logging/types/analytics.ts +81 -81
- package/src/logging/types/audit.ts +31 -31
- package/src/logging/types/index.ts +3 -3
- package/src/middleware/create-middleware.ts +1139 -1138
- package/src/middleware/rbac-check.ts +279 -279
- package/src/middleware/twofa-presets.ts +224 -224
- package/src/models/DecodedAccessToken.ts +16 -16
- package/src/models/SessionModel.ts +331 -331
- package/src/pages/admin-login/page.tsx +111 -111
- package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
- package/src/pages/admin-page-permissions/index.ts +7 -7
- package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
- package/src/pages/admin-roles/index.ts +9 -9
- package/src/pages/admin-roles/modals.tsx +469 -469
- package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
- package/src/pages/client-admin/index.ts +33 -33
- package/src/pages/coming-soon/page.tsx +83 -83
- package/src/pages/login/page.tsx +338 -338
- package/src/pages/onboarding/index.ts +11 -11
- package/src/pages/onboarding/page.tsx +212 -212
- package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
- package/src/pages/profile/index.ts +9 -9
- package/src/pages/profile/page.tsx +166 -166
- package/src/pages/recovery/page.tsx +267 -267
- package/src/pages/roles/MyRolesPage.tsx +211 -211
- package/src/pages/roles/components.tsx +294 -294
- package/src/pages/roles/index.ts +17 -17
- package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
- package/src/pages/security/index.ts +12 -12
- package/src/pages/security/page.tsx +507 -507
- package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
- package/src/pages/settings/index.ts +9 -9
- package/src/pages/settings/page.tsx +47 -47
- package/src/pages/showcase/ShowcasePage.tsx +532 -532
- package/src/pages/showcase/index.ts +13 -13
- package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
- package/src/pages/test-env/JwtInspectPage.tsx +449 -449
- package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
- package/src/pages/test-env/TestEnvPage.tsx +130 -130
- package/src/pages/test-env/guarded.tsx +81 -81
- package/src/pages/test-env/index.ts +77 -77
- package/src/pages/test-env/redact-credentials.ts +96 -96
- package/src/pages/verify-code/page.tsx +746 -746
- package/src/routes/account/index.ts +31 -31
- package/src/routes/account/masked-info.ts +36 -36
- package/src/routes/account/send-code.ts +39 -39
- package/src/routes/account/update-phone.ts +13 -13
- package/src/routes/account/verify-email.ts +40 -40
- package/src/routes/account/verify-sms.ts +40 -40
- package/src/routes/auth/credential-login-email-code.ts +6 -6
- package/src/routes/auth/credential-login.ts +6 -6
- package/src/routes/auth/index.ts +22 -22
- package/src/routes/auth/logout.ts +111 -111
- package/src/routes/auth/nextauth.ts +45 -45
- package/src/routes/auth/passkey-signin.ts +13 -13
- package/src/routes/auth/refresh.ts +77 -77
- package/src/routes/auth/session-reset.ts +37 -37
- package/src/routes/auth/session.ts +261 -261
- package/src/routes/auth/settings.ts +181 -181
- package/src/routes/auth/viability.ts +519 -519
- package/src/routes/index.ts +17 -17
- package/src/routes/mfa/backup-code-verify.ts +1 -1
- package/src/routes/mfa/backup-codes.ts +10 -10
- package/src/routes/mfa/methods.ts +22 -22
- package/src/routes/mfa/totp-confirm.ts +10 -10
- package/src/routes/mfa/totp-enroll.ts +10 -10
- package/src/routes/mfa/totp-verify.ts +14 -14
- package/src/routes/onboarding/index.ts +15 -15
- package/src/routes/onboarding/send-email-code.ts +2 -2
- package/src/routes/onboarding/set-password.ts +2 -2
- package/src/routes/onboarding/verify-code.ts +2 -2
- package/src/routes/onboarding/verify-welcome-email.ts +2 -2
- package/src/routes/passkeys/list.ts +10 -10
- package/src/routes/passkeys/manage.ts +23 -23
- package/src/routes/passkeys/register-options.ts +10 -10
- package/src/routes/passkeys/register-verify.ts +10 -10
- package/src/routes/session/index.ts +7 -7
- package/src/routes/session/refresh-viability.ts +17 -17
- package/src/server/auth-guard.ts +203 -203
- package/src/server/auth.ts +486 -486
- package/src/server/decode-session.ts +309 -309
- package/src/server/slim-middleware.ts +117 -117
- package/src/server/verified-session-token.ts +103 -103
- package/src/server/with-auth.ts +93 -93
- package/src/services/signalrActivityService.ts +258 -258
- package/src/stores/authStore.ts +1900 -1900
- package/src/templates/instrumentation.ts +60 -60
- package/src/theme/ThemeProvider.tsx +39 -39
- package/src/theme/default.ts +33 -33
- package/src/theme/index.ts +31 -31
- package/src/theme/types.ts +69 -69
- package/src/theme/useTheme.ts +57 -57
- package/src/theme/utils.ts +40 -40
- package/src/types/api.ts +12 -12
- package/src/types/auth.d.ts +23 -23
- package/src/types/auth.ts +48 -48
- package/src/types/logging.ts +11 -11
- package/src/types/recovery.ts +54 -54
- package/src/utils/api.ts +401 -401
- package/src/utils/circuitBreaker.ts +40 -40
- package/src/utils/error-message.ts +108 -108
- package/src/utils/layout/reservedSpace.ts +124 -124
- package/src/utils/logout.ts +30 -30
- package/src/vibe/client.ts +877 -877
- package/src/vibe/enterprise-auth.ts +208 -208
- package/src/vibe/errors.ts +209 -209
- package/src/vibe/generic.ts +339 -339
- package/src/vibe/hooks/index.ts +367 -367
- package/src/vibe/index.ts +133 -133
- package/src/vibe/sessions.ts +925 -899
- package/src/vibe/types.ts +577 -577
package/src/vibe/client.ts
CHANGED
|
@@ -1,877 +1,877 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* =============================================================================
|
|
3
|
-
* VIBE CLIENT - PRISMA-STYLE QUERY BUILDER
|
|
4
|
-
* =============================================================================
|
|
5
|
-
*
|
|
6
|
-
* A typed query builder for the Vibe App API.
|
|
7
|
-
* Provides Prisma-like DX for CRUD operations on Vibe tables.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* import { vibe } from '@payez/next-mvp/vibe'
|
|
11
|
-
*
|
|
12
|
-
* // Find many with filters
|
|
13
|
-
* const users = await vibe.users.findMany({
|
|
14
|
-
* where: { status: 'active' },
|
|
15
|
-
* take: 10,
|
|
16
|
-
* skip: 0,
|
|
17
|
-
* orderBy: { created_at: 'desc' }
|
|
18
|
-
* })
|
|
19
|
-
*
|
|
20
|
-
* // Find one by ID
|
|
21
|
-
* const user = await vibe.users.findUnique({ where: { id: 123 } })
|
|
22
|
-
*
|
|
23
|
-
* // Create
|
|
24
|
-
* const newUser = await vibe.users.create({
|
|
25
|
-
* data: { email: 'test@example.com', name: 'Test' }
|
|
26
|
-
* })
|
|
27
|
-
*
|
|
28
|
-
* // Update
|
|
29
|
-
* const updated = await vibe.users.update({
|
|
30
|
-
* where: { id: 123 },
|
|
31
|
-
* data: { name: 'Updated' }
|
|
32
|
-
* })
|
|
33
|
-
*
|
|
34
|
-
* // Delete (soft delete)
|
|
35
|
-
* await vibe.users.delete({ where: { id: 123 } })
|
|
36
|
-
*
|
|
37
|
-
* =============================================================================
|
|
38
|
-
*/
|
|
39
|
-
|
|
40
|
-
import {
|
|
41
|
-
VibeError,
|
|
42
|
-
VibeNotFoundError,
|
|
43
|
-
VibeServiceError,
|
|
44
|
-
VibeValidationError,
|
|
45
|
-
VibeBatchCreateError,
|
|
46
|
-
} from './errors';
|
|
47
|
-
import { unwrapVibeDocument, extractVibeDocuments } from './document-unwrap';
|
|
48
|
-
import type { VibeDocumentWrapper } from './document-unwrap';
|
|
49
|
-
import type {
|
|
50
|
-
VibeMeta,
|
|
51
|
-
VibeResponse,
|
|
52
|
-
VibeErrorResponse,
|
|
53
|
-
VibeTableName,
|
|
54
|
-
VibeTableType,
|
|
55
|
-
} from './types';
|
|
56
|
-
|
|
57
|
-
// Re-export from types for convenience
|
|
58
|
-
export type { VibeTableName, VibeTableType } from './types';
|
|
59
|
-
|
|
60
|
-
// -----------------------------------------------------------------------------
|
|
61
|
-
// CONFIGURATION
|
|
62
|
-
// -----------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
export interface VibeRetryConfig {
|
|
65
|
-
/** Max retries for 5xx errors (default: 3) */
|
|
66
|
-
maxRetries?: number;
|
|
67
|
-
/** Base delay in ms for exponential backoff (default: 1000) */
|
|
68
|
-
baseDelay?: number;
|
|
69
|
-
/** Max delay in ms (default: 10000) */
|
|
70
|
-
maxDelay?: number;
|
|
71
|
-
/** Whether to retry on 429 with Retry-After header (default: true) */
|
|
72
|
-
retryOnRateLimit?: boolean;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface VibeClientConfig {
|
|
76
|
-
/**
|
|
77
|
-
* Base URL for the Vibe API (defaults to VIBE_API_URL env var).
|
|
78
|
-
*
|
|
79
|
-
* PAY-1767: proxy mode does NOT need this. Every request is sent to the IDP proxy as an endpoint
|
|
80
|
-
* PATH (`/v1/collections/...`), which is what the proxy validates and signs - it never builds a
|
|
81
|
-
* Vibe URL from this value. It is retained only for compatibility with consumers that still read it.
|
|
82
|
-
*/
|
|
83
|
-
baseUrl?: string;
|
|
84
|
-
/** Vibe Client ID (defaults to VIBE_CLIENT_ID env var) */
|
|
85
|
-
clientId?: string;
|
|
86
|
-
/**
|
|
87
|
-
* IDP URL for proxy requests (defaults to IDP_URL env var)
|
|
88
|
-
* Used with appSigningKey for secure proxy-based Vibe access.
|
|
89
|
-
*/
|
|
90
|
-
idpUrl?: string;
|
|
91
|
-
/**
|
|
92
|
-
* Vibe HMAC key for proxy authentication (defaults to VIBE_HMAC_KEY env var)
|
|
93
|
-
* Base64-encoded signing key from Vibe Credentials tab.
|
|
94
|
-
* When set, requests are routed through the IDP proxy instead of directly to Vibe.
|
|
95
|
-
*/
|
|
96
|
-
appSigningKey?: string;
|
|
97
|
-
/**
|
|
98
|
-
* PAY-1767: which COLLECTION the table delegates address (defaults to VIBE_COLLECTION, then 'vibe_app').
|
|
99
|
-
*
|
|
100
|
-
* The Vibe API route is `/v1/collections/{collection}/tables/{table}`. This client used to hardcode
|
|
101
|
-
* `/api/v1/vibe_app/{table}` - a collection baked into the path with no route behind it. Pin the
|
|
102
|
-
* collection explicitly at the call site when your tables do not live in `vibe_app` (the UMI
|
|
103
|
-
* VIBE_COLLECTION-double-duty lesson, POST-INSTALL.md item 16).
|
|
104
|
-
*/
|
|
105
|
-
collection?: string;
|
|
106
|
-
/** Custom fetch function (for testing or custom auth) */
|
|
107
|
-
fetch?: typeof fetch;
|
|
108
|
-
/** Retry configuration for transient errors */
|
|
109
|
-
retry?: VibeRetryConfig;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// -----------------------------------------------------------------------------
|
|
113
|
-
// QUERY TYPES
|
|
114
|
-
// -----------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
/** Comparison operators for filters */
|
|
117
|
-
export type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in' | 'nin';
|
|
118
|
-
|
|
119
|
-
/** Filter value - can be simple value or operator object */
|
|
120
|
-
export type FilterValue<T> = T | { [K in FilterOperator]?: T };
|
|
121
|
-
|
|
122
|
-
/** Where clause for queries */
|
|
123
|
-
export type WhereClause<T> = {
|
|
124
|
-
[K in keyof T]?: FilterValue<T[K]>;
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
/** Order direction */
|
|
128
|
-
export type OrderDirection = 'asc' | 'desc';
|
|
129
|
-
|
|
130
|
-
/** Order by clause */
|
|
131
|
-
export type OrderByClause<T> = {
|
|
132
|
-
[K in keyof T]?: OrderDirection;
|
|
133
|
-
};
|
|
134
|
-
|
|
135
|
-
/** Options for findMany */
|
|
136
|
-
export interface FindManyOptions<T> {
|
|
137
|
-
where?: WhereClause<T>;
|
|
138
|
-
orderBy?: OrderByClause<T>;
|
|
139
|
-
take?: number;
|
|
140
|
-
skip?: number;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Options for findUnique.
|
|
145
|
-
*
|
|
146
|
-
* PAY-1767 (NightHawk 64534 MUST 2): `where.id` is the DOCUMENT id - the storage-layer primary key the
|
|
147
|
-
* route constrains (`{id:int}`), NOT a row's own `id` column. The unwrapped row exposes it as
|
|
148
|
-
* `_vibe_doc_id`; use THAT value for a subsequent update/delete.
|
|
149
|
-
*/
|
|
150
|
-
export interface FindUniqueOptions {
|
|
151
|
-
where: { id: number };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Options for create */
|
|
155
|
-
export interface CreateOptions<T> {
|
|
156
|
-
data: Omit<T, 'id' | 'created_at' | 'updated_at'>;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Options for update. `where.id` is the DOCUMENT id (see FindUniqueOptions), not a row's own id column.
|
|
161
|
-
*/
|
|
162
|
-
export interface UpdateOptions<T> {
|
|
163
|
-
where: { id: number };
|
|
164
|
-
data: Partial<Omit<T, 'id' | 'created_at' | 'updated_at'>>;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Options for delete. `where.id` is the DOCUMENT id (see FindUniqueOptions). */
|
|
168
|
-
export interface DeleteOptions {
|
|
169
|
-
where: { id: number };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** Options for count */
|
|
173
|
-
export interface CountOptions<T> {
|
|
174
|
-
where?: WhereClause<T>;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** Result of findMany with metadata */
|
|
178
|
-
export interface FindManyResult<T> {
|
|
179
|
-
data: T[];
|
|
180
|
-
/**
|
|
181
|
-
* PAY-1767 (NightHawk 64552 item B): true when `data` is a PAGE of a larger population. Read from Core's
|
|
182
|
-
* `pagination.totalCount`; a caller must not treat `data.length` as the table's size.
|
|
183
|
-
*/
|
|
184
|
-
hasMore?: boolean;
|
|
185
|
-
meta: VibeMeta;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// -----------------------------------------------------------------------------
|
|
189
|
-
// RETRY HELPERS
|
|
190
|
-
// -----------------------------------------------------------------------------
|
|
191
|
-
|
|
192
|
-
const DEFAULT_RETRY_CONFIG: Required<VibeRetryConfig> = {
|
|
193
|
-
maxRetries: 3,
|
|
194
|
-
baseDelay: 1000,
|
|
195
|
-
maxDelay: 10000,
|
|
196
|
-
retryOnRateLimit: true,
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* PAY-1767: the most records `createMany` will create in one call. It is per-record and NOT atomic, so a
|
|
201
|
-
* ceiling bounds the blast radius of a partial failure. 50 is the house's only batch ceiling (Vibe's own
|
|
202
|
-
* DecryptController BATCH_TOO_LARGE = 50).
|
|
203
|
-
*/
|
|
204
|
-
export const MAX_BATCH_CREATE_ROWS = 50;
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* PAY-1767: the default page size findMany asks the query route for. Core clamps pageSize/limit to 100
|
|
208
|
-
* (DocumentsController.Query), so this is the most one call returns.
|
|
209
|
-
*/
|
|
210
|
-
export const MAX_FIND_MANY_PAGE_SIZE = 100;
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* PAY-1767: the body POST /v1/collections/{c}/tables/{t}/query binds (Core QueryRequest). `filter` is a
|
|
214
|
-
* VibeFilterNode OBJECT - `{ "<field>": { "<operator>": value } }` - NOT the array sessions.ts sends.
|
|
215
|
-
*/
|
|
216
|
-
interface QueryRequestBody {
|
|
217
|
-
filter?: Record<string, unknown>;
|
|
218
|
-
orderBy?: Record<string, string>;
|
|
219
|
-
page: number;
|
|
220
|
-
pageSize: number;
|
|
221
|
-
/** QueryRequest.Limit is an alias for PageSize; findMany sets it so take:0 means count-only. */
|
|
222
|
-
limit?: number;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** The operators Core's VibeQueryBuilder accepts, from VibeOperators.All. */
|
|
226
|
-
const VIBE_OPERATORS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'contains', 'startsWith', 'endsWith', 'in', 'notIn', 'isNull', 'isNotNull', 'ilike']);
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* PAY-1767: map a Prisma-style `where` to Core's VibeFilterNode OBJECT.
|
|
230
|
-
*
|
|
231
|
-
* { status: 'active' } -> { 'status': { eq: 'active' } }
|
|
232
|
-
* { score: { gt: 5 } } -> { 'score': { gt: 5 } }
|
|
233
|
-
* { status: 'a', tier: { in: [] } } -> { AND: [ {status:{eq:'a'}}, {tier:{in:[]}} ] }
|
|
234
|
-
*
|
|
235
|
-
* A field name is used as-is: a top-level document column (document_id, created_at...) or a JSONB path
|
|
236
|
-
* (Core treats an unknown name as data->>'name'; a nested path like 'data.x' is honoured explicitly).
|
|
237
|
-
* An unknown OPERATOR throws - dropping an operator would widen the result silently.
|
|
238
|
-
*/
|
|
239
|
-
export function buildVibeFilter(where: Record<string, unknown>, table: string): Record<string, unknown> {
|
|
240
|
-
const leaves: Record<string, unknown>[] = [];
|
|
241
|
-
|
|
242
|
-
for (const [field, value] of Object.entries(where)) {
|
|
243
|
-
if (value === undefined) continue;
|
|
244
|
-
|
|
245
|
-
const conditions: Record<string, unknown> = {};
|
|
246
|
-
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
247
|
-
for (const [op, val] of Object.entries(value as Record<string, unknown>)) {
|
|
248
|
-
if (!VIBE_OPERATORS.has(op)) {
|
|
249
|
-
throw new VibeValidationError(
|
|
250
|
-
`where.${field} uses unsupported operator '${op}'. Core accepts: ${[...VIBE_OPERATORS].join(', ')}.`,
|
|
251
|
-
{ table, field, operator: op },
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
conditions[op] = val;
|
|
255
|
-
}
|
|
256
|
-
if (Object.keys(conditions).length === 0) {
|
|
257
|
-
throw new VibeValidationError(`where.${field} has no operator`, { table, field });
|
|
258
|
-
}
|
|
259
|
-
} else if (Array.isArray(value)) {
|
|
260
|
-
// SHOULD (NightHawk 64610): a bare array is a set membership, NOT equality. `{ id: [1,2] }` as
|
|
261
|
-
// eq:[1,2] compares a scalar to an array and matches nothing; map it to Core's `in`.
|
|
262
|
-
conditions.in = value;
|
|
263
|
-
} else if (value === null) {
|
|
264
|
-
// SHOULD (NightHawk 64610): `{ x: null }` as eq:null compares against JSON null; Core has a dedicated
|
|
265
|
-
// isNull operator, so map it rather than emit an equality that silently misses.
|
|
266
|
-
conditions.isNull = true;
|
|
267
|
-
} else {
|
|
268
|
-
// A bare value is equality.
|
|
269
|
-
conditions.eq = value;
|
|
270
|
-
}
|
|
271
|
-
leaves.push({ [field]: conditions });
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (leaves.length === 0) return {};
|
|
275
|
-
if (leaves.length === 1) return leaves[0];
|
|
276
|
-
// Multiple fields default to AND (Core's VibeFilterNode AND group).
|
|
277
|
-
return { AND: leaves };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/** Read the total from whichever pagination shape Core returned (DocumentListResponse.pagination). */
|
|
281
|
-
function readPaginationTotal(response: VibeResponse<unknown>): number | undefined {
|
|
282
|
-
const r = response as unknown as { pagination?: { totalCount?: number; total?: number }; meta?: { total?: number } };
|
|
283
|
-
return r.pagination?.totalCount ?? r.pagination?.total ?? r.meta?.total;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Sleep for a given number of milliseconds */
|
|
287
|
-
function sleep(ms: number): Promise<void> {
|
|
288
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** Calculate exponential backoff delay with jitter */
|
|
292
|
-
function getBackoffDelay(attempt: number, baseDelay: number, maxDelay: number): number {
|
|
293
|
-
// Exponential backoff: baseDelay * 2^attempt
|
|
294
|
-
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
|
295
|
-
// Add jitter (±25%) to prevent thundering herd
|
|
296
|
-
const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
|
|
297
|
-
return Math.min(exponentialDelay + jitter, maxDelay);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// -----------------------------------------------------------------------------
|
|
301
|
-
// TABLE DELEGATE
|
|
302
|
-
// -----------------------------------------------------------------------------
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Delegate class for table operations.
|
|
306
|
-
* Provides Prisma-style methods for a specific table.
|
|
307
|
-
*/
|
|
308
|
-
export class VibeTableDelegate<T extends VibeTableName> {
|
|
309
|
-
private tableName: T;
|
|
310
|
-
private client: VibeClient;
|
|
311
|
-
|
|
312
|
-
constructor(tableName: T, client: VibeClient) {
|
|
313
|
-
this.tableName = tableName;
|
|
314
|
-
this.client = client;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* PAY-1767: the Vibe API route for this table's document collection.
|
|
319
|
-
* `/v1/collections/{collection}/tables/{table}` (Core DocumentsController.cs:15) - the same shape
|
|
320
|
-
* generic.ts and sessions.ts already use. The OLD `/api/v1/vibe_app/{table}` had no route behind it.
|
|
321
|
-
*/
|
|
322
|
-
private tableEndpoint(id?: number | string): string {
|
|
323
|
-
return this.client.tableEndpoint(this.tableName, id);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Find multiple records, WITH filtering, sorting and paging honoured.
|
|
328
|
-
*
|
|
329
|
-
* PAY-1767 (NightHawk 64534 MUST 1, QAPert 64530, BAPert 64535). The GET list route
|
|
330
|
-
* (`GET /v1/collections/{c}/tables/{t}`, Core DocumentsController.List :44-48) binds ONLY `page` and
|
|
331
|
-
* `pageSize` - it reads no filter, sort or offset, so sending where/orderBy/take/skip there DROPPED them
|
|
332
|
-
* silently. Through the proxy that is worse than loud: a proxied MVP service account is ADMIN mode
|
|
333
|
-
* (HasValidContext), so the ownership filter is bypassed and an unfiltered page is 20 ARBITRARY rows of
|
|
334
|
-
* the tenant - caller code doing `rows[0]` gets the WRONG record with no error.
|
|
335
|
-
*
|
|
336
|
-
* So this now goes to `POST .../tables/{t}/query`, which binds QueryRequest (Filter / OrderBy / Page /
|
|
337
|
-
* PageSize / Limit). The FILTER is a VibeFilterNode OBJECT - `{ "<field>": { "<op>": value } }` - NOT the
|
|
338
|
-
* filter ARRAY that sessions.ts sends: QueryRequest.Filter is typed VibeFilterNode, which has no array
|
|
339
|
-
* form (measured at Core origin/master; CliInitProjectService.cs:197 builds the same object shape). An
|
|
340
|
-
* unmappable option THROWS rather than being dropped.
|
|
341
|
-
*/
|
|
342
|
-
async findMany(options?: FindManyOptions<VibeTableType<T>>): Promise<FindManyResult<VibeTableType<T>>> {
|
|
343
|
-
const body = this.buildQueryBody(options);
|
|
344
|
-
const endpoint = `${this.tableEndpoint()}/query`;
|
|
345
|
-
|
|
346
|
-
const response = await this.client.request<VibeDocumentWrapper[]>(endpoint, 'POST', body);
|
|
347
|
-
// MUST 2: the rows come back as document WRAPPERS; unwrap to the row's own fields.
|
|
348
|
-
const rows = extractVibeDocuments(response.data) as unknown as VibeTableType<T>[];
|
|
349
|
-
// item B (NightHawk 64552): the TRUE population is Core's pagination.totalCount, NOT a page size.
|
|
350
|
-
const total = readPaginationTotal(response) ?? rows.length;
|
|
351
|
-
const limit = body.limit ?? body.pageSize;
|
|
352
|
-
|
|
353
|
-
const offset = (body.page - 1) * limit;
|
|
354
|
-
return {
|
|
355
|
-
data: rows,
|
|
356
|
-
// item B: `hasMore` so a caller cannot mistake a page for the whole table.
|
|
357
|
-
// MUST 2 (NightHawk 64610): it must account for the OFFSET - on the last page `rows.length < total`
|
|
358
|
-
// was still true, so a caller paging until hasMore is false never stopped.
|
|
359
|
-
hasMore: offset + rows.length < total,
|
|
360
|
-
meta: { total, limit, offset },
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* PAY-1767: map the Prisma-style findMany options onto Core's QueryRequest body. THROWS on anything the
|
|
366
|
-
* query endpoint cannot express, so nothing is silently dropped.
|
|
367
|
-
*/
|
|
368
|
-
private buildQueryBody(options?: FindManyOptions<VibeTableType<T>>): QueryRequestBody {
|
|
369
|
-
const body: QueryRequestBody = { page: 1, pageSize: MAX_FIND_MANY_PAGE_SIZE };
|
|
370
|
-
|
|
371
|
-
if (options?.where && Object.keys(options.where).length > 0) {
|
|
372
|
-
body.filter = buildVibeFilter(options.where, this.tableName);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (options?.orderBy && Object.keys(options.orderBy).length > 0) {
|
|
376
|
-
const orderBy: Record<string, string> = {};
|
|
377
|
-
for (const [field, dir] of Object.entries(options.orderBy)) {
|
|
378
|
-
orderBy[field] = dir === 'desc' ? 'desc' : 'asc';
|
|
379
|
-
}
|
|
380
|
-
body.orderBy = orderBy;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (options?.take !== undefined) {
|
|
384
|
-
if (!Number.isInteger(options.take) || options.take < 0) {
|
|
385
|
-
throw new VibeValidationError(`take must be a non-negative integer (got ${options.take})`, { table: this.tableName });
|
|
386
|
-
}
|
|
387
|
-
// MUST 1 (NightHawk 64610): Core CLAMPS pageSize to 100 (DocumentsController.Query:
|
|
388
|
-
// `if (pageSize > 100) pageSize = 100`), so take > 100 would return 100 rows while meta.limit claimed
|
|
389
|
-
// the larger size - and a skip computed from a 250 size would land on Core's page 2 of size 100
|
|
390
|
-
// (rows 100-199), not rows 250-499. Refuse it rather than return the wrong rows silently.
|
|
391
|
-
if (options.take > MAX_FIND_MANY_PAGE_SIZE) {
|
|
392
|
-
throw new VibeValidationError(
|
|
393
|
-
`take ${options.take} exceeds the maximum page size ${MAX_FIND_MANY_PAGE_SIZE}; the query endpoint ` +
|
|
394
|
-
`CLAMPS pageSize to ${MAX_FIND_MANY_PAGE_SIZE}, so a larger take returns a short page while ` +
|
|
395
|
-
`reporting the larger limit. Page with take ${MAX_FIND_MANY_PAGE_SIZE} and a skip multiple.`,
|
|
396
|
-
{ table: this.tableName, take: options.take, max: MAX_FIND_MANY_PAGE_SIZE },
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
body.limit = options.take;
|
|
400
|
-
body.pageSize = options.take === 0 ? 0 : options.take;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (options?.skip !== undefined) {
|
|
404
|
-
if (!Number.isInteger(options.skip) || options.skip < 0) {
|
|
405
|
-
throw new VibeValidationError(`skip must be a non-negative integer (got ${options.skip})`, { table: this.tableName });
|
|
406
|
-
}
|
|
407
|
-
// The query endpoint PAGES (page + pageSize); it cannot express a bare row offset. A skip that is a
|
|
408
|
-
// whole number of pages can be expressed exactly; anything else would return rows BEFORE the offset
|
|
409
|
-
// and look correct. Refuse it.
|
|
410
|
-
const size = body.limit ?? body.pageSize;
|
|
411
|
-
if (body.limit === undefined && options.take === undefined && options.skip % size !== 0) {
|
|
412
|
-
throw new VibeValidationError(
|
|
413
|
-
`skip ${options.skip} is not a multiple of the default page size ${size}; pass a take so the page ` +
|
|
414
|
-
`size is explicit, or use a multiple (the query endpoint pages, it has no bare offset)`,
|
|
415
|
-
{ table: this.tableName, skip: options.skip },
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
if (size <= 0 || options.skip % size !== 0) {
|
|
419
|
-
throw new VibeValidationError(
|
|
420
|
-
`skip ${options.skip} cannot be expressed as a page of size ${size} (the query endpoint pages; ` +
|
|
421
|
-
`skip must be a multiple of take)`,
|
|
422
|
-
{ table: this.tableName, skip: options.skip, take: options.take },
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
body.page = options.skip / size + 1;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
return body;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* Find a single record by ID.
|
|
433
|
-
* Throws VibeNotFoundError if not found.
|
|
434
|
-
*/
|
|
435
|
-
async findUnique(options: FindUniqueOptions): Promise<VibeTableType<T>> {
|
|
436
|
-
const endpoint = this.tableEndpoint(options.where.id);
|
|
437
|
-
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'GET');
|
|
438
|
-
// PAY-1767: unwrap the document wrapper so the caller gets the row's own fields.
|
|
439
|
-
const row = unwrapVibeDocument<VibeTableType<T>>(response.data);
|
|
440
|
-
if (!row) {
|
|
441
|
-
throw new VibeNotFoundError(
|
|
442
|
-
`Document ${options.where.id} not found in ${this.tableName}`,
|
|
443
|
-
'NOT_FOUND',
|
|
444
|
-
{ table: this.tableName, id: options.where.id },
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
return row as VibeTableType<T>;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/**
|
|
451
|
-
* Find a single record by ID.
|
|
452
|
-
* Returns null if not found (instead of throwing).
|
|
453
|
-
*/
|
|
454
|
-
async findUniqueOrNull(options: FindUniqueOptions): Promise<VibeTableType<T> | null> {
|
|
455
|
-
try {
|
|
456
|
-
return await this.findUnique(options);
|
|
457
|
-
} catch (error) {
|
|
458
|
-
if (error instanceof VibeNotFoundError) {
|
|
459
|
-
return null;
|
|
460
|
-
}
|
|
461
|
-
throw error;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Find the first record.
|
|
467
|
-
* Returns null if none found.
|
|
468
|
-
*
|
|
469
|
-
* PAY-1767: takes the first row of the (caller-owned, created_at-ordered) list. Any where/orderBy in
|
|
470
|
-
* `options` is passed through to findMany, which REFUSES them on this route rather than dropping them,
|
|
471
|
-
* so this cannot pretend to filter.
|
|
472
|
-
*/
|
|
473
|
-
async findFirst(options?: FindManyOptions<VibeTableType<T>>): Promise<VibeTableType<T> | null> {
|
|
474
|
-
const result = await this.findMany(options);
|
|
475
|
-
return result.data[0] || null;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Create a new record.
|
|
480
|
-
*/
|
|
481
|
-
async create(options: CreateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
|
|
482
|
-
const endpoint = this.tableEndpoint();
|
|
483
|
-
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'POST', options.data);
|
|
484
|
-
// SHOULD (NightHawk 64534): unwrap; a success with no data THROWS rather than returning {} typed as a row.
|
|
485
|
-
return this.requireRow(response.data, 'create');
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Update an existing record by ID.
|
|
490
|
-
* Throws VibeNotFoundError if not found.
|
|
491
|
-
*/
|
|
492
|
-
async update(options: UpdateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
|
|
493
|
-
const endpoint = this.tableEndpoint(options.where.id);
|
|
494
|
-
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'PUT', options.data);
|
|
495
|
-
return this.requireRow(response.data, 'update');
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Delete a record by ID (soft delete).
|
|
500
|
-
* Throws VibeNotFoundError if not found.
|
|
501
|
-
*
|
|
502
|
-
* PAY-1767 (NightHawk 64578 item 2): Core answers DELETE with **204 No Content** (DocumentsController
|
|
503
|
-
* Delete returns NoContent()), so a SUCCESSFUL delete carries NO document - and there is none to unwrap.
|
|
504
|
-
* This must therefore NOT go through requireRow (that is the create/update rule: "a success WITH no
|
|
505
|
-
* document throws"). A 2xx with no body is the success here; the request layer already resolves it to an
|
|
506
|
-
* empty success. Returns null when the server sent no document.
|
|
507
|
-
*/
|
|
508
|
-
async delete(options: DeleteOptions): Promise<VibeTableType<T> | null> {
|
|
509
|
-
const endpoint = this.tableEndpoint(options.where.id);
|
|
510
|
-
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'DELETE');
|
|
511
|
-
return response?.data ? (unwrapVibeDocument<VibeTableType<T>>(response.data) as VibeTableType<T>) : null;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
/** Unwrap a single-document response, or THROW - never return an empty object typed as a row. */
|
|
515
|
-
private requireRow(wire: VibeDocumentWrapper | undefined, op: string): VibeTableType<T> {
|
|
516
|
-
const row = wire ? unwrapVibeDocument<VibeTableType<T>>(wire) : null;
|
|
517
|
-
if (!row) {
|
|
518
|
-
throw new VibeNotFoundError(
|
|
519
|
-
`${op} on ${this.tableName} returned no document data`,
|
|
520
|
-
'NOT_FOUND',
|
|
521
|
-
{ table: this.tableName, op },
|
|
522
|
-
);
|
|
523
|
-
}
|
|
524
|
-
return row as VibeTableType<T>;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Count records matching the filter.
|
|
529
|
-
*
|
|
530
|
-
* PAY-1767 (NightHawk 64534): goes to POST /query with limit:0, which is Core's COUNT-ONLY mode
|
|
531
|
-
* (DocumentsController.Query returns an empty Data array plus Pagination.TotalCount). So this is the
|
|
532
|
-
* true population of the filter, not the first page's size.
|
|
533
|
-
*/
|
|
534
|
-
async count(options?: CountOptions<VibeTableType<T>>): Promise<number> {
|
|
535
|
-
const body = this.buildQueryBody({ where: options?.where, take: 0 } as FindManyOptions<VibeTableType<T>>);
|
|
536
|
-
const endpoint = `${this.tableEndpoint()}/query`;
|
|
537
|
-
const response = await this.client.request<VibeDocumentWrapper[]>(endpoint, 'POST', body);
|
|
538
|
-
return readPaginationTotal(response) ?? 0;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/**
|
|
542
|
-
* Create multiple records.
|
|
543
|
-
*
|
|
544
|
-
* PAY-1767: the old path `/api/v1/vibe_app/batch` has no route (there is no documents batch endpoint in
|
|
545
|
-
* Core's Vibe Public API), so this CANNOT be atomic here. Until a Core batch route exists, this creates
|
|
546
|
-
* each record against the REAL table endpoint in turn.
|
|
547
|
-
*
|
|
548
|
-
* BOUNDED ON PURPOSE (BAPert 64527). Non-atomic is tolerable only while the blast radius is small, so a
|
|
549
|
-
* request above MAX_BATCH_CREATE_ROWS is REFUSED before anything is sent. 50 matches the house's only
|
|
550
|
-
* batch ceiling (Vibe's DecryptController BATCH_TOO_LARGE).
|
|
551
|
-
*
|
|
552
|
-
* PARTIAL FAILURE IS REPORTED, NOT SWALLOWED (NightHawk 64534 condition (a)). A failure at item k throws
|
|
553
|
-
* a VibeBatchCreateError that CARRIES the count and the document ids already created - a cap cannot fix
|
|
554
|
-
* partial failure, only reporting it can. The created rows are unwrapped (MUST 2) so their own fields are
|
|
555
|
-
* present, not the wrapper's.
|
|
556
|
-
*/
|
|
557
|
-
async createMany(options: { data: CreateOptions<VibeTableType<T>>['data'][] }): Promise<{ count: number; ids: Array<number | string> }> {
|
|
558
|
-
if (options.data.length > MAX_BATCH_CREATE_ROWS) {
|
|
559
|
-
throw new VibeServiceError(
|
|
560
|
-
`createMany accepts at most ${MAX_BATCH_CREATE_ROWS} records per call (got ${options.data.length}); ` +
|
|
561
|
-
`it is NOT atomic, so a larger batch could leave a partial write`,
|
|
562
|
-
);
|
|
563
|
-
}
|
|
564
|
-
const endpoint = this.tableEndpoint();
|
|
565
|
-
const created: Array<number | string> = [];
|
|
566
|
-
for (let i = 0; i < options.data.length; i++) {
|
|
567
|
-
try {
|
|
568
|
-
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'POST', options.data[i]);
|
|
569
|
-
const row = unwrapVibeDocument<{ _vibe_doc_id?: number | string }>(response.data);
|
|
570
|
-
if (row?._vibe_doc_id != null) created.push(row._vibe_doc_id);
|
|
571
|
-
} catch (err) {
|
|
572
|
-
throw new VibeBatchCreateError(
|
|
573
|
-
`createMany created ${created.length} of ${options.data.length} records before failing at index ${i}`,
|
|
574
|
-
{ createdCount: created.length, createdIds: created, failedIndex: i, total: options.data.length },
|
|
575
|
-
err,
|
|
576
|
-
);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
return { count: created.length, ids: created };
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// -----------------------------------------------------------------------------
|
|
584
|
-
// VIBE CLIENT
|
|
585
|
-
// -----------------------------------------------------------------------------
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Main Vibe client class.
|
|
589
|
-
* Provides access to all Vibe App tables via table delegates.
|
|
590
|
-
*/
|
|
591
|
-
export class VibeClient {
|
|
592
|
-
private config: {
|
|
593
|
-
baseUrl: string;
|
|
594
|
-
clientId: string;
|
|
595
|
-
idpUrl: string;
|
|
596
|
-
appSigningKey: string;
|
|
597
|
-
/** PAY-1767: the collection the table delegates address. */
|
|
598
|
-
collection: string;
|
|
599
|
-
fetch: typeof fetch;
|
|
600
|
-
retry: Required<VibeRetryConfig>;
|
|
601
|
-
};
|
|
602
|
-
|
|
603
|
-
// Table delegates
|
|
604
|
-
public users: VibeTableDelegate<'users'>;
|
|
605
|
-
public login_sessions: VibeTableDelegate<'login_sessions'>;
|
|
606
|
-
public profiles: VibeTableDelegate<'profiles'>;
|
|
607
|
-
public settings: VibeTableDelegate<'settings'>;
|
|
608
|
-
public files: VibeTableDelegate<'files'>;
|
|
609
|
-
public notifications: VibeTableDelegate<'notifications'>;
|
|
610
|
-
public activity_log: VibeTableDelegate<'activity_log'>;
|
|
611
|
-
public tags: VibeTableDelegate<'tags'>;
|
|
612
|
-
public comments: VibeTableDelegate<'comments'>;
|
|
613
|
-
public site_logs: VibeTableDelegate<'site_logs'>;
|
|
614
|
-
|
|
615
|
-
constructor(config: VibeClientConfig = {}) {
|
|
616
|
-
this.config = {
|
|
617
|
-
baseUrl: config.baseUrl || process.env.VIBE_API_URL || '',
|
|
618
|
-
clientId: config.clientId || process.env.VIBE_CLIENT_ID || '',
|
|
619
|
-
idpUrl: config.idpUrl || process.env.IDP_URL || '',
|
|
620
|
-
appSigningKey: config.appSigningKey || process.env.VIBE_HMAC_KEY || '',
|
|
621
|
-
// PAY-1767: which collection the table delegates address. Library default is correct ('vibe_app');
|
|
622
|
-
// pinning it per-call avoids the VIBE_COLLECTION-global-retarget trap (POST-INSTALL.md item 16).
|
|
623
|
-
collection: config.collection || process.env.VIBE_COLLECTION || 'vibe_app',
|
|
624
|
-
fetch: config.fetch || globalThis.fetch,
|
|
625
|
-
retry: { ...DEFAULT_RETRY_CONFIG, ...config.retry },
|
|
626
|
-
};
|
|
627
|
-
|
|
628
|
-
// Initialize table delegates
|
|
629
|
-
this.users = new VibeTableDelegate('users', this);
|
|
630
|
-
this.login_sessions = new VibeTableDelegate('login_sessions', this);
|
|
631
|
-
this.profiles = new VibeTableDelegate('profiles', this);
|
|
632
|
-
this.settings = new VibeTableDelegate('settings', this);
|
|
633
|
-
this.files = new VibeTableDelegate('files', this);
|
|
634
|
-
this.notifications = new VibeTableDelegate('notifications', this);
|
|
635
|
-
this.activity_log = new VibeTableDelegate('activity_log', this);
|
|
636
|
-
this.tags = new VibeTableDelegate('tags', this);
|
|
637
|
-
this.comments = new VibeTableDelegate('comments', this);
|
|
638
|
-
this.site_logs = new VibeTableDelegate('site_logs', this);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
* Check if the client is configured to use proxy mode (IDP proxy).
|
|
643
|
-
* MVP always uses proxy mode - direct Vibe access is not supported.
|
|
644
|
-
*/
|
|
645
|
-
private useProxyMode(): boolean {
|
|
646
|
-
return !!this.config.appSigningKey && !!this.config.idpUrl;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/**
|
|
650
|
-
* Generate HMAC-SHA256 signature for IDP proxy authentication.
|
|
651
|
-
* Message format: {timestamp}|{method}|{endpoint}
|
|
652
|
-
*/
|
|
653
|
-
private async generateProxySignature(timestamp: number, method: string, endpoint: string): Promise<string> {
|
|
654
|
-
const message = `${timestamp}|${method}|${endpoint}`;
|
|
655
|
-
const keyBuffer = Buffer.from(this.config.appSigningKey, 'base64');
|
|
656
|
-
|
|
657
|
-
// Use Web Crypto API for browser/edge compatibility, fall back to Node crypto
|
|
658
|
-
if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle) {
|
|
659
|
-
const encoder = new TextEncoder();
|
|
660
|
-
const key = await globalThis.crypto.subtle.importKey(
|
|
661
|
-
'raw',
|
|
662
|
-
keyBuffer,
|
|
663
|
-
{ name: 'HMAC', hash: 'SHA-256' },
|
|
664
|
-
false,
|
|
665
|
-
['sign']
|
|
666
|
-
);
|
|
667
|
-
const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(message));
|
|
668
|
-
return Buffer.from(signature).toString('base64');
|
|
669
|
-
} else {
|
|
670
|
-
// Node.js fallback
|
|
671
|
-
const crypto = await import('crypto');
|
|
672
|
-
return crypto.createHmac('sha256', keyBuffer).update(message).digest('base64');
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* PAY-1767: the Vibe API endpoint PATH for a table in this client's collection.
|
|
678
|
-
* `/v1/collections/{collection}/tables/{table}[/{id}]` - what the IDP proxy validates, signs and forwards.
|
|
679
|
-
* Returned as a PATH, not a URL: proxy mode needs no VIBE_API_URL (the proxy owns the base).
|
|
680
|
-
*/
|
|
681
|
-
tableEndpoint(table: string, id?: number | string): string {
|
|
682
|
-
const base = `/v1/collections/${this.config.collection}/tables/${table}`;
|
|
683
|
-
return id === undefined ? base : `${base}/${id}`;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
/**
|
|
687
|
-
* Make a request through the IDP Vibe proxy.
|
|
688
|
-
* The proxy handles credential injection securely.
|
|
689
|
-
*
|
|
690
|
-
* PAY-1767: `endpoint` is the RAW path (+query) to forward. It is what gets signed and sent, so this
|
|
691
|
-
* takes a path directly rather than parsing one out of a URL - proxy mode is complete without a Vibe
|
|
692
|
-
* base URL, which is exactly what an endpoint-path call needs.
|
|
693
|
-
*/
|
|
694
|
-
private async requestViaProxy<T>(
|
|
695
|
-
endpoint: string,
|
|
696
|
-
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
697
|
-
body?: unknown,
|
|
698
|
-
authToken?: string
|
|
699
|
-
): Promise<VibeResponse<T>> {
|
|
700
|
-
const timestamp = Math.floor(Date.now() / 1000);
|
|
701
|
-
const signature = await this.generateProxySignature(timestamp, method, endpoint);
|
|
702
|
-
|
|
703
|
-
const proxyUrl = `${this.config.idpUrl}/api/vibe/proxy`;
|
|
704
|
-
|
|
705
|
-
const headers: HeadersInit = {
|
|
706
|
-
'Content-Type': 'application/json',
|
|
707
|
-
'X-Vibe-Client-Id': this.config.clientId,
|
|
708
|
-
'X-Vibe-Timestamp': String(timestamp),
|
|
709
|
-
'X-Vibe-Signature': signature,
|
|
710
|
-
};
|
|
711
|
-
|
|
712
|
-
// Pass through auth token if provided (for user context)
|
|
713
|
-
if (authToken) {
|
|
714
|
-
headers['Authorization'] = `Bearer ${authToken}`;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// PAY-1767: the proxy binds Endpoint / Method / Data (Core VibeProxyController :376-391), so a write
|
|
718
|
-
// goes out as `data` - sending `body` (as this used to) arrived as data:null.
|
|
719
|
-
const proxyBody = {
|
|
720
|
-
method,
|
|
721
|
-
endpoint,
|
|
722
|
-
...(body !== undefined && { data: body }),
|
|
723
|
-
};
|
|
724
|
-
|
|
725
|
-
const response = await this.config.fetch(proxyUrl, {
|
|
726
|
-
method: 'POST',
|
|
727
|
-
headers,
|
|
728
|
-
body: JSON.stringify(proxyBody),
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
if (!response.ok) {
|
|
732
|
-
const errorBody = await this.parseErrorResponse(response);
|
|
733
|
-
throw VibeError.fromResponse(errorBody, response.status);
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// PAY-1767 (NightHawk 64578 item 2): a 204 No Content (or any 2xx with an EMPTY body) is a SUCCESS with
|
|
737
|
-
// no payload - e.g. a successful DELETE. Parsing it as JSON throws, which surfaced a successful delete as
|
|
738
|
-
// an error. Return an empty success here; this is distinct from the SHOULD rule (that one throws when a
|
|
739
|
-
// create/update answers success but carries NO DOCUMENT), which lives in the delegates.
|
|
740
|
-
if (response.status === 204) {
|
|
741
|
-
return { success: true } as unknown as VibeResponse<T>;
|
|
742
|
-
}
|
|
743
|
-
const raw = await response.text();
|
|
744
|
-
if (raw.trim() === '') {
|
|
745
|
-
return { success: true } as unknown as VibeResponse<T>;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
let responseData: unknown;
|
|
749
|
-
try {
|
|
750
|
-
responseData = JSON.parse(raw);
|
|
751
|
-
} catch {
|
|
752
|
-
// A non-empty 2xx body that is not JSON is not a shape we can trust - fail rather than pretend.
|
|
753
|
-
throw new VibeServiceError(
|
|
754
|
-
`Vibe API answered ${response.status} with a body that is not JSON`,
|
|
755
|
-
{ status: response.status, bodyLength: raw.length },
|
|
756
|
-
);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
if (!(responseData as VibeResponse<T>).success) {
|
|
760
|
-
throw VibeError.fromResponse(responseData as VibeErrorResponse, response.status);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
return responseData as VibeResponse<T>;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* Get the configured base URL.
|
|
768
|
-
*/
|
|
769
|
-
getBaseUrl(): string {
|
|
770
|
-
return this.config.baseUrl;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/**
|
|
774
|
-
* Make an authenticated request to the Vibe API with smart retry logic.
|
|
775
|
-
*
|
|
776
|
-
* Routing:
|
|
777
|
-
* - All requests route through the IDP proxy (requires VIBE_HMAC_KEY + IDP_URL)
|
|
778
|
-
*
|
|
779
|
-
* PAY-1767: `urlOrPath` is EITHER a full URL or a bare Vibe path ('/v1/collections/...'). Proxy mode
|
|
780
|
-
* signs and forwards the PATH either way, so a caller with only a path (the table delegates) needs no
|
|
781
|
-
* Vibe base URL - VIBE_API_URL is not required to make a proxied call.
|
|
782
|
-
*
|
|
783
|
-
* Retry behavior:
|
|
784
|
-
* - 5xx errors: Retry up to maxRetries times with exponential backoff
|
|
785
|
-
* - 429 errors: Retry once after Retry-After delay (if retryOnRateLimit enabled)
|
|
786
|
-
* - 401/403 errors: No retry (auth errors)
|
|
787
|
-
* - Network errors: Retry with backoff
|
|
788
|
-
*/
|
|
789
|
-
async request<T>(
|
|
790
|
-
urlOrPath: string,
|
|
791
|
-
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
792
|
-
body?: unknown,
|
|
793
|
-
authToken?: string
|
|
794
|
-
): Promise<VibeResponse<T>> {
|
|
795
|
-
if (!this.config.clientId) {
|
|
796
|
-
throw new VibeServiceError('VIBE_CLIENT_ID is not configured');
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Check if we should use proxy mode
|
|
800
|
-
if (this.useProxyMode()) {
|
|
801
|
-
if (!this.config.idpUrl) {
|
|
802
|
-
throw new VibeServiceError('IDP_URL is not configured (required for proxy mode)');
|
|
803
|
-
}
|
|
804
|
-
if (!this.config.appSigningKey) {
|
|
805
|
-
throw new VibeServiceError('VIBE_HMAC_KEY is not configured (required for proxy mode)');
|
|
806
|
-
}
|
|
807
|
-
// PAY-1767: a bare path is used as-is; a full URL is reduced to its path+query. Both sign identically.
|
|
808
|
-
const endpoint = urlOrPath.startsWith('/') ? urlOrPath : (() => {
|
|
809
|
-
const u = new URL(urlOrPath);
|
|
810
|
-
return u.pathname + u.search;
|
|
811
|
-
})();
|
|
812
|
-
// Route through IDP proxy
|
|
813
|
-
return this.requestViaProxy<T>(endpoint, method, body, authToken);
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// MVP requires proxy mode - direct Vibe access is not supported
|
|
817
|
-
throw new VibeServiceError(
|
|
818
|
-
'Vibe proxy not configured. Set VIBE_HMAC_KEY + IDP_URL for proxy mode.'
|
|
819
|
-
);
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/**
|
|
823
|
-
* Parse error response body, handling non-JSON responses gracefully.
|
|
824
|
-
*/
|
|
825
|
-
private async parseErrorResponse(response: Response): Promise<VibeErrorResponse> {
|
|
826
|
-
try {
|
|
827
|
-
return await response.json();
|
|
828
|
-
} catch {
|
|
829
|
-
// Non-JSON error response
|
|
830
|
-
const text = await response.text().catch(() => 'Unknown error');
|
|
831
|
-
return {
|
|
832
|
-
success: false,
|
|
833
|
-
error: {
|
|
834
|
-
code: `HTTP_${response.status}`,
|
|
835
|
-
message: text || response.statusText,
|
|
836
|
-
},
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Fetch the schema for type generation.
|
|
843
|
-
*
|
|
844
|
-
* PAY-1767: the old path `/api/v1/vibe_app/schema` has no route. The real per-collection schema route is
|
|
845
|
-
* `/v1/mvp/schemas/{collection}` (Core MvpSchemaController.cs:65). Returns the collection schema DTO.
|
|
846
|
-
*/
|
|
847
|
-
async getSchema(): Promise<Record<string, unknown>> {
|
|
848
|
-
const endpoint = `/v1/mvp/schemas/${this.config.collection}`;
|
|
849
|
-
const response = await this.request<Record<string, unknown>>(endpoint, 'GET');
|
|
850
|
-
return response.data;
|
|
851
|
-
}
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
// -----------------------------------------------------------------------------
|
|
855
|
-
// SINGLETON INSTANCE
|
|
856
|
-
// -----------------------------------------------------------------------------
|
|
857
|
-
|
|
858
|
-
/**
|
|
859
|
-
* Default Vibe client instance.
|
|
860
|
-
* Uses environment variables for configuration.
|
|
861
|
-
*
|
|
862
|
-
* Usage:
|
|
863
|
-
* import { vibe } from '@payez/next-mvp/vibe'
|
|
864
|
-
* const users = await vibe.users.findMany()
|
|
865
|
-
*/
|
|
866
|
-
export const vibe = new VibeClient();
|
|
867
|
-
|
|
868
|
-
/**
|
|
869
|
-
* Create a new Vibe client with custom configuration.
|
|
870
|
-
*
|
|
871
|
-
* Usage:
|
|
872
|
-
* import { createVibeClient } from '@payez/next-mvp/vibe'
|
|
873
|
-
* const customVibe = createVibeClient({ baseUrl: 'https://custom-api.example.com' })
|
|
874
|
-
*/
|
|
875
|
-
export function createVibeClient(config: VibeClientConfig): VibeClient {
|
|
876
|
-
return new VibeClient(config);
|
|
877
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* =============================================================================
|
|
3
|
+
* VIBE CLIENT - PRISMA-STYLE QUERY BUILDER
|
|
4
|
+
* =============================================================================
|
|
5
|
+
*
|
|
6
|
+
* A typed query builder for the Vibe App API.
|
|
7
|
+
* Provides Prisma-like DX for CRUD operations on Vibe tables.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { vibe } from '@payez/next-mvp/vibe'
|
|
11
|
+
*
|
|
12
|
+
* // Find many with filters
|
|
13
|
+
* const users = await vibe.users.findMany({
|
|
14
|
+
* where: { status: 'active' },
|
|
15
|
+
* take: 10,
|
|
16
|
+
* skip: 0,
|
|
17
|
+
* orderBy: { created_at: 'desc' }
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* // Find one by ID
|
|
21
|
+
* const user = await vibe.users.findUnique({ where: { id: 123 } })
|
|
22
|
+
*
|
|
23
|
+
* // Create
|
|
24
|
+
* const newUser = await vibe.users.create({
|
|
25
|
+
* data: { email: 'test@example.com', name: 'Test' }
|
|
26
|
+
* })
|
|
27
|
+
*
|
|
28
|
+
* // Update
|
|
29
|
+
* const updated = await vibe.users.update({
|
|
30
|
+
* where: { id: 123 },
|
|
31
|
+
* data: { name: 'Updated' }
|
|
32
|
+
* })
|
|
33
|
+
*
|
|
34
|
+
* // Delete (soft delete)
|
|
35
|
+
* await vibe.users.delete({ where: { id: 123 } })
|
|
36
|
+
*
|
|
37
|
+
* =============================================================================
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import {
|
|
41
|
+
VibeError,
|
|
42
|
+
VibeNotFoundError,
|
|
43
|
+
VibeServiceError,
|
|
44
|
+
VibeValidationError,
|
|
45
|
+
VibeBatchCreateError,
|
|
46
|
+
} from './errors';
|
|
47
|
+
import { unwrapVibeDocument, extractVibeDocuments } from './document-unwrap';
|
|
48
|
+
import type { VibeDocumentWrapper } from './document-unwrap';
|
|
49
|
+
import type {
|
|
50
|
+
VibeMeta,
|
|
51
|
+
VibeResponse,
|
|
52
|
+
VibeErrorResponse,
|
|
53
|
+
VibeTableName,
|
|
54
|
+
VibeTableType,
|
|
55
|
+
} from './types';
|
|
56
|
+
|
|
57
|
+
// Re-export from types for convenience
|
|
58
|
+
export type { VibeTableName, VibeTableType } from './types';
|
|
59
|
+
|
|
60
|
+
// -----------------------------------------------------------------------------
|
|
61
|
+
// CONFIGURATION
|
|
62
|
+
// -----------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export interface VibeRetryConfig {
|
|
65
|
+
/** Max retries for 5xx errors (default: 3) */
|
|
66
|
+
maxRetries?: number;
|
|
67
|
+
/** Base delay in ms for exponential backoff (default: 1000) */
|
|
68
|
+
baseDelay?: number;
|
|
69
|
+
/** Max delay in ms (default: 10000) */
|
|
70
|
+
maxDelay?: number;
|
|
71
|
+
/** Whether to retry on 429 with Retry-After header (default: true) */
|
|
72
|
+
retryOnRateLimit?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface VibeClientConfig {
|
|
76
|
+
/**
|
|
77
|
+
* Base URL for the Vibe API (defaults to VIBE_API_URL env var).
|
|
78
|
+
*
|
|
79
|
+
* PAY-1767: proxy mode does NOT need this. Every request is sent to the IDP proxy as an endpoint
|
|
80
|
+
* PATH (`/v1/collections/...`), which is what the proxy validates and signs - it never builds a
|
|
81
|
+
* Vibe URL from this value. It is retained only for compatibility with consumers that still read it.
|
|
82
|
+
*/
|
|
83
|
+
baseUrl?: string;
|
|
84
|
+
/** Vibe Client ID (defaults to VIBE_CLIENT_ID env var) */
|
|
85
|
+
clientId?: string;
|
|
86
|
+
/**
|
|
87
|
+
* IDP URL for proxy requests (defaults to IDP_URL env var)
|
|
88
|
+
* Used with appSigningKey for secure proxy-based Vibe access.
|
|
89
|
+
*/
|
|
90
|
+
idpUrl?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Vibe HMAC key for proxy authentication (defaults to VIBE_HMAC_KEY env var)
|
|
93
|
+
* Base64-encoded signing key from Vibe Credentials tab.
|
|
94
|
+
* When set, requests are routed through the IDP proxy instead of directly to Vibe.
|
|
95
|
+
*/
|
|
96
|
+
appSigningKey?: string;
|
|
97
|
+
/**
|
|
98
|
+
* PAY-1767: which COLLECTION the table delegates address (defaults to VIBE_COLLECTION, then 'vibe_app').
|
|
99
|
+
*
|
|
100
|
+
* The Vibe API route is `/v1/collections/{collection}/tables/{table}`. This client used to hardcode
|
|
101
|
+
* `/api/v1/vibe_app/{table}` - a collection baked into the path with no route behind it. Pin the
|
|
102
|
+
* collection explicitly at the call site when your tables do not live in `vibe_app` (the UMI
|
|
103
|
+
* VIBE_COLLECTION-double-duty lesson, POST-INSTALL.md item 16).
|
|
104
|
+
*/
|
|
105
|
+
collection?: string;
|
|
106
|
+
/** Custom fetch function (for testing or custom auth) */
|
|
107
|
+
fetch?: typeof fetch;
|
|
108
|
+
/** Retry configuration for transient errors */
|
|
109
|
+
retry?: VibeRetryConfig;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -----------------------------------------------------------------------------
|
|
113
|
+
// QUERY TYPES
|
|
114
|
+
// -----------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/** Comparison operators for filters */
|
|
117
|
+
export type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in' | 'nin';
|
|
118
|
+
|
|
119
|
+
/** Filter value - can be simple value or operator object */
|
|
120
|
+
export type FilterValue<T> = T | { [K in FilterOperator]?: T };
|
|
121
|
+
|
|
122
|
+
/** Where clause for queries */
|
|
123
|
+
export type WhereClause<T> = {
|
|
124
|
+
[K in keyof T]?: FilterValue<T[K]>;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Order direction */
|
|
128
|
+
export type OrderDirection = 'asc' | 'desc';
|
|
129
|
+
|
|
130
|
+
/** Order by clause */
|
|
131
|
+
export type OrderByClause<T> = {
|
|
132
|
+
[K in keyof T]?: OrderDirection;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Options for findMany */
|
|
136
|
+
export interface FindManyOptions<T> {
|
|
137
|
+
where?: WhereClause<T>;
|
|
138
|
+
orderBy?: OrderByClause<T>;
|
|
139
|
+
take?: number;
|
|
140
|
+
skip?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Options for findUnique.
|
|
145
|
+
*
|
|
146
|
+
* PAY-1767 (NightHawk 64534 MUST 2): `where.id` is the DOCUMENT id - the storage-layer primary key the
|
|
147
|
+
* route constrains (`{id:int}`), NOT a row's own `id` column. The unwrapped row exposes it as
|
|
148
|
+
* `_vibe_doc_id`; use THAT value for a subsequent update/delete.
|
|
149
|
+
*/
|
|
150
|
+
export interface FindUniqueOptions {
|
|
151
|
+
where: { id: number };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Options for create */
|
|
155
|
+
export interface CreateOptions<T> {
|
|
156
|
+
data: Omit<T, 'id' | 'created_at' | 'updated_at'>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Options for update. `where.id` is the DOCUMENT id (see FindUniqueOptions), not a row's own id column.
|
|
161
|
+
*/
|
|
162
|
+
export interface UpdateOptions<T> {
|
|
163
|
+
where: { id: number };
|
|
164
|
+
data: Partial<Omit<T, 'id' | 'created_at' | 'updated_at'>>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Options for delete. `where.id` is the DOCUMENT id (see FindUniqueOptions). */
|
|
168
|
+
export interface DeleteOptions {
|
|
169
|
+
where: { id: number };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Options for count */
|
|
173
|
+
export interface CountOptions<T> {
|
|
174
|
+
where?: WhereClause<T>;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Result of findMany with metadata */
|
|
178
|
+
export interface FindManyResult<T> {
|
|
179
|
+
data: T[];
|
|
180
|
+
/**
|
|
181
|
+
* PAY-1767 (NightHawk 64552 item B): true when `data` is a PAGE of a larger population. Read from Core's
|
|
182
|
+
* `pagination.totalCount`; a caller must not treat `data.length` as the table's size.
|
|
183
|
+
*/
|
|
184
|
+
hasMore?: boolean;
|
|
185
|
+
meta: VibeMeta;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// -----------------------------------------------------------------------------
|
|
189
|
+
// RETRY HELPERS
|
|
190
|
+
// -----------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
const DEFAULT_RETRY_CONFIG: Required<VibeRetryConfig> = {
|
|
193
|
+
maxRetries: 3,
|
|
194
|
+
baseDelay: 1000,
|
|
195
|
+
maxDelay: 10000,
|
|
196
|
+
retryOnRateLimit: true,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* PAY-1767: the most records `createMany` will create in one call. It is per-record and NOT atomic, so a
|
|
201
|
+
* ceiling bounds the blast radius of a partial failure. 50 is the house's only batch ceiling (Vibe's own
|
|
202
|
+
* DecryptController BATCH_TOO_LARGE = 50).
|
|
203
|
+
*/
|
|
204
|
+
export const MAX_BATCH_CREATE_ROWS = 50;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* PAY-1767: the default page size findMany asks the query route for. Core clamps pageSize/limit to 100
|
|
208
|
+
* (DocumentsController.Query), so this is the most one call returns.
|
|
209
|
+
*/
|
|
210
|
+
export const MAX_FIND_MANY_PAGE_SIZE = 100;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* PAY-1767: the body POST /v1/collections/{c}/tables/{t}/query binds (Core QueryRequest). `filter` is a
|
|
214
|
+
* VibeFilterNode OBJECT - `{ "<field>": { "<operator>": value } }` - NOT the array sessions.ts sends.
|
|
215
|
+
*/
|
|
216
|
+
interface QueryRequestBody {
|
|
217
|
+
filter?: Record<string, unknown>;
|
|
218
|
+
orderBy?: Record<string, string>;
|
|
219
|
+
page: number;
|
|
220
|
+
pageSize: number;
|
|
221
|
+
/** QueryRequest.Limit is an alias for PageSize; findMany sets it so take:0 means count-only. */
|
|
222
|
+
limit?: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The operators Core's VibeQueryBuilder accepts, from VibeOperators.All. */
|
|
226
|
+
const VIBE_OPERATORS = new Set(['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'contains', 'startsWith', 'endsWith', 'in', 'notIn', 'isNull', 'isNotNull', 'ilike']);
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* PAY-1767: map a Prisma-style `where` to Core's VibeFilterNode OBJECT.
|
|
230
|
+
*
|
|
231
|
+
* { status: 'active' } -> { 'status': { eq: 'active' } }
|
|
232
|
+
* { score: { gt: 5 } } -> { 'score': { gt: 5 } }
|
|
233
|
+
* { status: 'a', tier: { in: [] } } -> { AND: [ {status:{eq:'a'}}, {tier:{in:[]}} ] }
|
|
234
|
+
*
|
|
235
|
+
* A field name is used as-is: a top-level document column (document_id, created_at...) or a JSONB path
|
|
236
|
+
* (Core treats an unknown name as data->>'name'; a nested path like 'data.x' is honoured explicitly).
|
|
237
|
+
* An unknown OPERATOR throws - dropping an operator would widen the result silently.
|
|
238
|
+
*/
|
|
239
|
+
export function buildVibeFilter(where: Record<string, unknown>, table: string): Record<string, unknown> {
|
|
240
|
+
const leaves: Record<string, unknown>[] = [];
|
|
241
|
+
|
|
242
|
+
for (const [field, value] of Object.entries(where)) {
|
|
243
|
+
if (value === undefined) continue;
|
|
244
|
+
|
|
245
|
+
const conditions: Record<string, unknown> = {};
|
|
246
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
247
|
+
for (const [op, val] of Object.entries(value as Record<string, unknown>)) {
|
|
248
|
+
if (!VIBE_OPERATORS.has(op)) {
|
|
249
|
+
throw new VibeValidationError(
|
|
250
|
+
`where.${field} uses unsupported operator '${op}'. Core accepts: ${[...VIBE_OPERATORS].join(', ')}.`,
|
|
251
|
+
{ table, field, operator: op },
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
conditions[op] = val;
|
|
255
|
+
}
|
|
256
|
+
if (Object.keys(conditions).length === 0) {
|
|
257
|
+
throw new VibeValidationError(`where.${field} has no operator`, { table, field });
|
|
258
|
+
}
|
|
259
|
+
} else if (Array.isArray(value)) {
|
|
260
|
+
// SHOULD (NightHawk 64610): a bare array is a set membership, NOT equality. `{ id: [1,2] }` as
|
|
261
|
+
// eq:[1,2] compares a scalar to an array and matches nothing; map it to Core's `in`.
|
|
262
|
+
conditions.in = value;
|
|
263
|
+
} else if (value === null) {
|
|
264
|
+
// SHOULD (NightHawk 64610): `{ x: null }` as eq:null compares against JSON null; Core has a dedicated
|
|
265
|
+
// isNull operator, so map it rather than emit an equality that silently misses.
|
|
266
|
+
conditions.isNull = true;
|
|
267
|
+
} else {
|
|
268
|
+
// A bare value is equality.
|
|
269
|
+
conditions.eq = value;
|
|
270
|
+
}
|
|
271
|
+
leaves.push({ [field]: conditions });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (leaves.length === 0) return {};
|
|
275
|
+
if (leaves.length === 1) return leaves[0];
|
|
276
|
+
// Multiple fields default to AND (Core's VibeFilterNode AND group).
|
|
277
|
+
return { AND: leaves };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Read the total from whichever pagination shape Core returned (DocumentListResponse.pagination). */
|
|
281
|
+
function readPaginationTotal(response: VibeResponse<unknown>): number | undefined {
|
|
282
|
+
const r = response as unknown as { pagination?: { totalCount?: number; total?: number }; meta?: { total?: number } };
|
|
283
|
+
return r.pagination?.totalCount ?? r.pagination?.total ?? r.meta?.total;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Sleep for a given number of milliseconds */
|
|
287
|
+
function sleep(ms: number): Promise<void> {
|
|
288
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Calculate exponential backoff delay with jitter */
|
|
292
|
+
function getBackoffDelay(attempt: number, baseDelay: number, maxDelay: number): number {
|
|
293
|
+
// Exponential backoff: baseDelay * 2^attempt
|
|
294
|
+
const exponentialDelay = baseDelay * Math.pow(2, attempt);
|
|
295
|
+
// Add jitter (±25%) to prevent thundering herd
|
|
296
|
+
const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
|
|
297
|
+
return Math.min(exponentialDelay + jitter, maxDelay);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// -----------------------------------------------------------------------------
|
|
301
|
+
// TABLE DELEGATE
|
|
302
|
+
// -----------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Delegate class for table operations.
|
|
306
|
+
* Provides Prisma-style methods for a specific table.
|
|
307
|
+
*/
|
|
308
|
+
export class VibeTableDelegate<T extends VibeTableName> {
|
|
309
|
+
private tableName: T;
|
|
310
|
+
private client: VibeClient;
|
|
311
|
+
|
|
312
|
+
constructor(tableName: T, client: VibeClient) {
|
|
313
|
+
this.tableName = tableName;
|
|
314
|
+
this.client = client;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* PAY-1767: the Vibe API route for this table's document collection.
|
|
319
|
+
* `/v1/collections/{collection}/tables/{table}` (Core DocumentsController.cs:15) - the same shape
|
|
320
|
+
* generic.ts and sessions.ts already use. The OLD `/api/v1/vibe_app/{table}` had no route behind it.
|
|
321
|
+
*/
|
|
322
|
+
private tableEndpoint(id?: number | string): string {
|
|
323
|
+
return this.client.tableEndpoint(this.tableName, id);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Find multiple records, WITH filtering, sorting and paging honoured.
|
|
328
|
+
*
|
|
329
|
+
* PAY-1767 (NightHawk 64534 MUST 1, QAPert 64530, BAPert 64535). The GET list route
|
|
330
|
+
* (`GET /v1/collections/{c}/tables/{t}`, Core DocumentsController.List :44-48) binds ONLY `page` and
|
|
331
|
+
* `pageSize` - it reads no filter, sort or offset, so sending where/orderBy/take/skip there DROPPED them
|
|
332
|
+
* silently. Through the proxy that is worse than loud: a proxied MVP service account is ADMIN mode
|
|
333
|
+
* (HasValidContext), so the ownership filter is bypassed and an unfiltered page is 20 ARBITRARY rows of
|
|
334
|
+
* the tenant - caller code doing `rows[0]` gets the WRONG record with no error.
|
|
335
|
+
*
|
|
336
|
+
* So this now goes to `POST .../tables/{t}/query`, which binds QueryRequest (Filter / OrderBy / Page /
|
|
337
|
+
* PageSize / Limit). The FILTER is a VibeFilterNode OBJECT - `{ "<field>": { "<op>": value } }` - NOT the
|
|
338
|
+
* filter ARRAY that sessions.ts sends: QueryRequest.Filter is typed VibeFilterNode, which has no array
|
|
339
|
+
* form (measured at Core origin/master; CliInitProjectService.cs:197 builds the same object shape). An
|
|
340
|
+
* unmappable option THROWS rather than being dropped.
|
|
341
|
+
*/
|
|
342
|
+
async findMany(options?: FindManyOptions<VibeTableType<T>>): Promise<FindManyResult<VibeTableType<T>>> {
|
|
343
|
+
const body = this.buildQueryBody(options);
|
|
344
|
+
const endpoint = `${this.tableEndpoint()}/query`;
|
|
345
|
+
|
|
346
|
+
const response = await this.client.request<VibeDocumentWrapper[]>(endpoint, 'POST', body);
|
|
347
|
+
// MUST 2: the rows come back as document WRAPPERS; unwrap to the row's own fields.
|
|
348
|
+
const rows = extractVibeDocuments(response.data) as unknown as VibeTableType<T>[];
|
|
349
|
+
// item B (NightHawk 64552): the TRUE population is Core's pagination.totalCount, NOT a page size.
|
|
350
|
+
const total = readPaginationTotal(response) ?? rows.length;
|
|
351
|
+
const limit = body.limit ?? body.pageSize;
|
|
352
|
+
|
|
353
|
+
const offset = (body.page - 1) * limit;
|
|
354
|
+
return {
|
|
355
|
+
data: rows,
|
|
356
|
+
// item B: `hasMore` so a caller cannot mistake a page for the whole table.
|
|
357
|
+
// MUST 2 (NightHawk 64610): it must account for the OFFSET - on the last page `rows.length < total`
|
|
358
|
+
// was still true, so a caller paging until hasMore is false never stopped.
|
|
359
|
+
hasMore: offset + rows.length < total,
|
|
360
|
+
meta: { total, limit, offset },
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* PAY-1767: map the Prisma-style findMany options onto Core's QueryRequest body. THROWS on anything the
|
|
366
|
+
* query endpoint cannot express, so nothing is silently dropped.
|
|
367
|
+
*/
|
|
368
|
+
private buildQueryBody(options?: FindManyOptions<VibeTableType<T>>): QueryRequestBody {
|
|
369
|
+
const body: QueryRequestBody = { page: 1, pageSize: MAX_FIND_MANY_PAGE_SIZE };
|
|
370
|
+
|
|
371
|
+
if (options?.where && Object.keys(options.where).length > 0) {
|
|
372
|
+
body.filter = buildVibeFilter(options.where, this.tableName);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (options?.orderBy && Object.keys(options.orderBy).length > 0) {
|
|
376
|
+
const orderBy: Record<string, string> = {};
|
|
377
|
+
for (const [field, dir] of Object.entries(options.orderBy)) {
|
|
378
|
+
orderBy[field] = dir === 'desc' ? 'desc' : 'asc';
|
|
379
|
+
}
|
|
380
|
+
body.orderBy = orderBy;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (options?.take !== undefined) {
|
|
384
|
+
if (!Number.isInteger(options.take) || options.take < 0) {
|
|
385
|
+
throw new VibeValidationError(`take must be a non-negative integer (got ${options.take})`, { table: this.tableName });
|
|
386
|
+
}
|
|
387
|
+
// MUST 1 (NightHawk 64610): Core CLAMPS pageSize to 100 (DocumentsController.Query:
|
|
388
|
+
// `if (pageSize > 100) pageSize = 100`), so take > 100 would return 100 rows while meta.limit claimed
|
|
389
|
+
// the larger size - and a skip computed from a 250 size would land on Core's page 2 of size 100
|
|
390
|
+
// (rows 100-199), not rows 250-499. Refuse it rather than return the wrong rows silently.
|
|
391
|
+
if (options.take > MAX_FIND_MANY_PAGE_SIZE) {
|
|
392
|
+
throw new VibeValidationError(
|
|
393
|
+
`take ${options.take} exceeds the maximum page size ${MAX_FIND_MANY_PAGE_SIZE}; the query endpoint ` +
|
|
394
|
+
`CLAMPS pageSize to ${MAX_FIND_MANY_PAGE_SIZE}, so a larger take returns a short page while ` +
|
|
395
|
+
`reporting the larger limit. Page with take ${MAX_FIND_MANY_PAGE_SIZE} and a skip multiple.`,
|
|
396
|
+
{ table: this.tableName, take: options.take, max: MAX_FIND_MANY_PAGE_SIZE },
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
body.limit = options.take;
|
|
400
|
+
body.pageSize = options.take === 0 ? 0 : options.take;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (options?.skip !== undefined) {
|
|
404
|
+
if (!Number.isInteger(options.skip) || options.skip < 0) {
|
|
405
|
+
throw new VibeValidationError(`skip must be a non-negative integer (got ${options.skip})`, { table: this.tableName });
|
|
406
|
+
}
|
|
407
|
+
// The query endpoint PAGES (page + pageSize); it cannot express a bare row offset. A skip that is a
|
|
408
|
+
// whole number of pages can be expressed exactly; anything else would return rows BEFORE the offset
|
|
409
|
+
// and look correct. Refuse it.
|
|
410
|
+
const size = body.limit ?? body.pageSize;
|
|
411
|
+
if (body.limit === undefined && options.take === undefined && options.skip % size !== 0) {
|
|
412
|
+
throw new VibeValidationError(
|
|
413
|
+
`skip ${options.skip} is not a multiple of the default page size ${size}; pass a take so the page ` +
|
|
414
|
+
`size is explicit, or use a multiple (the query endpoint pages, it has no bare offset)`,
|
|
415
|
+
{ table: this.tableName, skip: options.skip },
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (size <= 0 || options.skip % size !== 0) {
|
|
419
|
+
throw new VibeValidationError(
|
|
420
|
+
`skip ${options.skip} cannot be expressed as a page of size ${size} (the query endpoint pages; ` +
|
|
421
|
+
`skip must be a multiple of take)`,
|
|
422
|
+
{ table: this.tableName, skip: options.skip, take: options.take },
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
body.page = options.skip / size + 1;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return body;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Find a single record by ID.
|
|
433
|
+
* Throws VibeNotFoundError if not found.
|
|
434
|
+
*/
|
|
435
|
+
async findUnique(options: FindUniqueOptions): Promise<VibeTableType<T>> {
|
|
436
|
+
const endpoint = this.tableEndpoint(options.where.id);
|
|
437
|
+
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'GET');
|
|
438
|
+
// PAY-1767: unwrap the document wrapper so the caller gets the row's own fields.
|
|
439
|
+
const row = unwrapVibeDocument<VibeTableType<T>>(response.data);
|
|
440
|
+
if (!row) {
|
|
441
|
+
throw new VibeNotFoundError(
|
|
442
|
+
`Document ${options.where.id} not found in ${this.tableName}`,
|
|
443
|
+
'NOT_FOUND',
|
|
444
|
+
{ table: this.tableName, id: options.where.id },
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return row as VibeTableType<T>;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Find a single record by ID.
|
|
452
|
+
* Returns null if not found (instead of throwing).
|
|
453
|
+
*/
|
|
454
|
+
async findUniqueOrNull(options: FindUniqueOptions): Promise<VibeTableType<T> | null> {
|
|
455
|
+
try {
|
|
456
|
+
return await this.findUnique(options);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error instanceof VibeNotFoundError) {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Find the first record.
|
|
467
|
+
* Returns null if none found.
|
|
468
|
+
*
|
|
469
|
+
* PAY-1767: takes the first row of the (caller-owned, created_at-ordered) list. Any where/orderBy in
|
|
470
|
+
* `options` is passed through to findMany, which REFUSES them on this route rather than dropping them,
|
|
471
|
+
* so this cannot pretend to filter.
|
|
472
|
+
*/
|
|
473
|
+
async findFirst(options?: FindManyOptions<VibeTableType<T>>): Promise<VibeTableType<T> | null> {
|
|
474
|
+
const result = await this.findMany(options);
|
|
475
|
+
return result.data[0] || null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Create a new record.
|
|
480
|
+
*/
|
|
481
|
+
async create(options: CreateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
|
|
482
|
+
const endpoint = this.tableEndpoint();
|
|
483
|
+
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'POST', options.data);
|
|
484
|
+
// SHOULD (NightHawk 64534): unwrap; a success with no data THROWS rather than returning {} typed as a row.
|
|
485
|
+
return this.requireRow(response.data, 'create');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Update an existing record by ID.
|
|
490
|
+
* Throws VibeNotFoundError if not found.
|
|
491
|
+
*/
|
|
492
|
+
async update(options: UpdateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
|
|
493
|
+
const endpoint = this.tableEndpoint(options.where.id);
|
|
494
|
+
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'PUT', options.data);
|
|
495
|
+
return this.requireRow(response.data, 'update');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Delete a record by ID (soft delete).
|
|
500
|
+
* Throws VibeNotFoundError if not found.
|
|
501
|
+
*
|
|
502
|
+
* PAY-1767 (NightHawk 64578 item 2): Core answers DELETE with **204 No Content** (DocumentsController
|
|
503
|
+
* Delete returns NoContent()), so a SUCCESSFUL delete carries NO document - and there is none to unwrap.
|
|
504
|
+
* This must therefore NOT go through requireRow (that is the create/update rule: "a success WITH no
|
|
505
|
+
* document throws"). A 2xx with no body is the success here; the request layer already resolves it to an
|
|
506
|
+
* empty success. Returns null when the server sent no document.
|
|
507
|
+
*/
|
|
508
|
+
async delete(options: DeleteOptions): Promise<VibeTableType<T> | null> {
|
|
509
|
+
const endpoint = this.tableEndpoint(options.where.id);
|
|
510
|
+
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'DELETE');
|
|
511
|
+
return response?.data ? (unwrapVibeDocument<VibeTableType<T>>(response.data) as VibeTableType<T>) : null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Unwrap a single-document response, or THROW - never return an empty object typed as a row. */
|
|
515
|
+
private requireRow(wire: VibeDocumentWrapper | undefined, op: string): VibeTableType<T> {
|
|
516
|
+
const row = wire ? unwrapVibeDocument<VibeTableType<T>>(wire) : null;
|
|
517
|
+
if (!row) {
|
|
518
|
+
throw new VibeNotFoundError(
|
|
519
|
+
`${op} on ${this.tableName} returned no document data`,
|
|
520
|
+
'NOT_FOUND',
|
|
521
|
+
{ table: this.tableName, op },
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
return row as VibeTableType<T>;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Count records matching the filter.
|
|
529
|
+
*
|
|
530
|
+
* PAY-1767 (NightHawk 64534): goes to POST /query with limit:0, which is Core's COUNT-ONLY mode
|
|
531
|
+
* (DocumentsController.Query returns an empty Data array plus Pagination.TotalCount). So this is the
|
|
532
|
+
* true population of the filter, not the first page's size.
|
|
533
|
+
*/
|
|
534
|
+
async count(options?: CountOptions<VibeTableType<T>>): Promise<number> {
|
|
535
|
+
const body = this.buildQueryBody({ where: options?.where, take: 0 } as FindManyOptions<VibeTableType<T>>);
|
|
536
|
+
const endpoint = `${this.tableEndpoint()}/query`;
|
|
537
|
+
const response = await this.client.request<VibeDocumentWrapper[]>(endpoint, 'POST', body);
|
|
538
|
+
return readPaginationTotal(response) ?? 0;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Create multiple records.
|
|
543
|
+
*
|
|
544
|
+
* PAY-1767: the old path `/api/v1/vibe_app/batch` has no route (there is no documents batch endpoint in
|
|
545
|
+
* Core's Vibe Public API), so this CANNOT be atomic here. Until a Core batch route exists, this creates
|
|
546
|
+
* each record against the REAL table endpoint in turn.
|
|
547
|
+
*
|
|
548
|
+
* BOUNDED ON PURPOSE (BAPert 64527). Non-atomic is tolerable only while the blast radius is small, so a
|
|
549
|
+
* request above MAX_BATCH_CREATE_ROWS is REFUSED before anything is sent. 50 matches the house's only
|
|
550
|
+
* batch ceiling (Vibe's DecryptController BATCH_TOO_LARGE).
|
|
551
|
+
*
|
|
552
|
+
* PARTIAL FAILURE IS REPORTED, NOT SWALLOWED (NightHawk 64534 condition (a)). A failure at item k throws
|
|
553
|
+
* a VibeBatchCreateError that CARRIES the count and the document ids already created - a cap cannot fix
|
|
554
|
+
* partial failure, only reporting it can. The created rows are unwrapped (MUST 2) so their own fields are
|
|
555
|
+
* present, not the wrapper's.
|
|
556
|
+
*/
|
|
557
|
+
async createMany(options: { data: CreateOptions<VibeTableType<T>>['data'][] }): Promise<{ count: number; ids: Array<number | string> }> {
|
|
558
|
+
if (options.data.length > MAX_BATCH_CREATE_ROWS) {
|
|
559
|
+
throw new VibeServiceError(
|
|
560
|
+
`createMany accepts at most ${MAX_BATCH_CREATE_ROWS} records per call (got ${options.data.length}); ` +
|
|
561
|
+
`it is NOT atomic, so a larger batch could leave a partial write`,
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
const endpoint = this.tableEndpoint();
|
|
565
|
+
const created: Array<number | string> = [];
|
|
566
|
+
for (let i = 0; i < options.data.length; i++) {
|
|
567
|
+
try {
|
|
568
|
+
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'POST', options.data[i]);
|
|
569
|
+
const row = unwrapVibeDocument<{ _vibe_doc_id?: number | string }>(response.data);
|
|
570
|
+
if (row?._vibe_doc_id != null) created.push(row._vibe_doc_id);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
throw new VibeBatchCreateError(
|
|
573
|
+
`createMany created ${created.length} of ${options.data.length} records before failing at index ${i}`,
|
|
574
|
+
{ createdCount: created.length, createdIds: created, failedIndex: i, total: options.data.length },
|
|
575
|
+
err,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return { count: created.length, ids: created };
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// -----------------------------------------------------------------------------
|
|
584
|
+
// VIBE CLIENT
|
|
585
|
+
// -----------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Main Vibe client class.
|
|
589
|
+
* Provides access to all Vibe App tables via table delegates.
|
|
590
|
+
*/
|
|
591
|
+
export class VibeClient {
|
|
592
|
+
private config: {
|
|
593
|
+
baseUrl: string;
|
|
594
|
+
clientId: string;
|
|
595
|
+
idpUrl: string;
|
|
596
|
+
appSigningKey: string;
|
|
597
|
+
/** PAY-1767: the collection the table delegates address. */
|
|
598
|
+
collection: string;
|
|
599
|
+
fetch: typeof fetch;
|
|
600
|
+
retry: Required<VibeRetryConfig>;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
// Table delegates
|
|
604
|
+
public users: VibeTableDelegate<'users'>;
|
|
605
|
+
public login_sessions: VibeTableDelegate<'login_sessions'>;
|
|
606
|
+
public profiles: VibeTableDelegate<'profiles'>;
|
|
607
|
+
public settings: VibeTableDelegate<'settings'>;
|
|
608
|
+
public files: VibeTableDelegate<'files'>;
|
|
609
|
+
public notifications: VibeTableDelegate<'notifications'>;
|
|
610
|
+
public activity_log: VibeTableDelegate<'activity_log'>;
|
|
611
|
+
public tags: VibeTableDelegate<'tags'>;
|
|
612
|
+
public comments: VibeTableDelegate<'comments'>;
|
|
613
|
+
public site_logs: VibeTableDelegate<'site_logs'>;
|
|
614
|
+
|
|
615
|
+
constructor(config: VibeClientConfig = {}) {
|
|
616
|
+
this.config = {
|
|
617
|
+
baseUrl: config.baseUrl || process.env.VIBE_API_URL || '',
|
|
618
|
+
clientId: config.clientId || process.env.VIBE_CLIENT_ID || '',
|
|
619
|
+
idpUrl: config.idpUrl || process.env.IDP_URL || '',
|
|
620
|
+
appSigningKey: config.appSigningKey || process.env.VIBE_HMAC_KEY || '',
|
|
621
|
+
// PAY-1767: which collection the table delegates address. Library default is correct ('vibe_app');
|
|
622
|
+
// pinning it per-call avoids the VIBE_COLLECTION-global-retarget trap (POST-INSTALL.md item 16).
|
|
623
|
+
collection: config.collection || process.env.VIBE_COLLECTION || 'vibe_app',
|
|
624
|
+
fetch: config.fetch || globalThis.fetch,
|
|
625
|
+
retry: { ...DEFAULT_RETRY_CONFIG, ...config.retry },
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
// Initialize table delegates
|
|
629
|
+
this.users = new VibeTableDelegate('users', this);
|
|
630
|
+
this.login_sessions = new VibeTableDelegate('login_sessions', this);
|
|
631
|
+
this.profiles = new VibeTableDelegate('profiles', this);
|
|
632
|
+
this.settings = new VibeTableDelegate('settings', this);
|
|
633
|
+
this.files = new VibeTableDelegate('files', this);
|
|
634
|
+
this.notifications = new VibeTableDelegate('notifications', this);
|
|
635
|
+
this.activity_log = new VibeTableDelegate('activity_log', this);
|
|
636
|
+
this.tags = new VibeTableDelegate('tags', this);
|
|
637
|
+
this.comments = new VibeTableDelegate('comments', this);
|
|
638
|
+
this.site_logs = new VibeTableDelegate('site_logs', this);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Check if the client is configured to use proxy mode (IDP proxy).
|
|
643
|
+
* MVP always uses proxy mode - direct Vibe access is not supported.
|
|
644
|
+
*/
|
|
645
|
+
private useProxyMode(): boolean {
|
|
646
|
+
return !!this.config.appSigningKey && !!this.config.idpUrl;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Generate HMAC-SHA256 signature for IDP proxy authentication.
|
|
651
|
+
* Message format: {timestamp}|{method}|{endpoint}
|
|
652
|
+
*/
|
|
653
|
+
private async generateProxySignature(timestamp: number, method: string, endpoint: string): Promise<string> {
|
|
654
|
+
const message = `${timestamp}|${method}|${endpoint}`;
|
|
655
|
+
const keyBuffer = Buffer.from(this.config.appSigningKey, 'base64');
|
|
656
|
+
|
|
657
|
+
// Use Web Crypto API for browser/edge compatibility, fall back to Node crypto
|
|
658
|
+
if (typeof globalThis.crypto !== 'undefined' && globalThis.crypto.subtle) {
|
|
659
|
+
const encoder = new TextEncoder();
|
|
660
|
+
const key = await globalThis.crypto.subtle.importKey(
|
|
661
|
+
'raw',
|
|
662
|
+
keyBuffer,
|
|
663
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
664
|
+
false,
|
|
665
|
+
['sign']
|
|
666
|
+
);
|
|
667
|
+
const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(message));
|
|
668
|
+
return Buffer.from(signature).toString('base64');
|
|
669
|
+
} else {
|
|
670
|
+
// Node.js fallback
|
|
671
|
+
const crypto = await import('crypto');
|
|
672
|
+
return crypto.createHmac('sha256', keyBuffer).update(message).digest('base64');
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* PAY-1767: the Vibe API endpoint PATH for a table in this client's collection.
|
|
678
|
+
* `/v1/collections/{collection}/tables/{table}[/{id}]` - what the IDP proxy validates, signs and forwards.
|
|
679
|
+
* Returned as a PATH, not a URL: proxy mode needs no VIBE_API_URL (the proxy owns the base).
|
|
680
|
+
*/
|
|
681
|
+
tableEndpoint(table: string, id?: number | string): string {
|
|
682
|
+
const base = `/v1/collections/${this.config.collection}/tables/${table}`;
|
|
683
|
+
return id === undefined ? base : `${base}/${id}`;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Make a request through the IDP Vibe proxy.
|
|
688
|
+
* The proxy handles credential injection securely.
|
|
689
|
+
*
|
|
690
|
+
* PAY-1767: `endpoint` is the RAW path (+query) to forward. It is what gets signed and sent, so this
|
|
691
|
+
* takes a path directly rather than parsing one out of a URL - proxy mode is complete without a Vibe
|
|
692
|
+
* base URL, which is exactly what an endpoint-path call needs.
|
|
693
|
+
*/
|
|
694
|
+
private async requestViaProxy<T>(
|
|
695
|
+
endpoint: string,
|
|
696
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
697
|
+
body?: unknown,
|
|
698
|
+
authToken?: string
|
|
699
|
+
): Promise<VibeResponse<T>> {
|
|
700
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
701
|
+
const signature = await this.generateProxySignature(timestamp, method, endpoint);
|
|
702
|
+
|
|
703
|
+
const proxyUrl = `${this.config.idpUrl}/api/vibe/proxy`;
|
|
704
|
+
|
|
705
|
+
const headers: HeadersInit = {
|
|
706
|
+
'Content-Type': 'application/json',
|
|
707
|
+
'X-Vibe-Client-Id': this.config.clientId,
|
|
708
|
+
'X-Vibe-Timestamp': String(timestamp),
|
|
709
|
+
'X-Vibe-Signature': signature,
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// Pass through auth token if provided (for user context)
|
|
713
|
+
if (authToken) {
|
|
714
|
+
headers['Authorization'] = `Bearer ${authToken}`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// PAY-1767: the proxy binds Endpoint / Method / Data (Core VibeProxyController :376-391), so a write
|
|
718
|
+
// goes out as `data` - sending `body` (as this used to) arrived as data:null.
|
|
719
|
+
const proxyBody = {
|
|
720
|
+
method,
|
|
721
|
+
endpoint,
|
|
722
|
+
...(body !== undefined && { data: body }),
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const response = await this.config.fetch(proxyUrl, {
|
|
726
|
+
method: 'POST',
|
|
727
|
+
headers,
|
|
728
|
+
body: JSON.stringify(proxyBody),
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
if (!response.ok) {
|
|
732
|
+
const errorBody = await this.parseErrorResponse(response);
|
|
733
|
+
throw VibeError.fromResponse(errorBody, response.status);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// PAY-1767 (NightHawk 64578 item 2): a 204 No Content (or any 2xx with an EMPTY body) is a SUCCESS with
|
|
737
|
+
// no payload - e.g. a successful DELETE. Parsing it as JSON throws, which surfaced a successful delete as
|
|
738
|
+
// an error. Return an empty success here; this is distinct from the SHOULD rule (that one throws when a
|
|
739
|
+
// create/update answers success but carries NO DOCUMENT), which lives in the delegates.
|
|
740
|
+
if (response.status === 204) {
|
|
741
|
+
return { success: true } as unknown as VibeResponse<T>;
|
|
742
|
+
}
|
|
743
|
+
const raw = await response.text();
|
|
744
|
+
if (raw.trim() === '') {
|
|
745
|
+
return { success: true } as unknown as VibeResponse<T>;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
let responseData: unknown;
|
|
749
|
+
try {
|
|
750
|
+
responseData = JSON.parse(raw);
|
|
751
|
+
} catch {
|
|
752
|
+
// A non-empty 2xx body that is not JSON is not a shape we can trust - fail rather than pretend.
|
|
753
|
+
throw new VibeServiceError(
|
|
754
|
+
`Vibe API answered ${response.status} with a body that is not JSON`,
|
|
755
|
+
{ status: response.status, bodyLength: raw.length },
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (!(responseData as VibeResponse<T>).success) {
|
|
760
|
+
throw VibeError.fromResponse(responseData as VibeErrorResponse, response.status);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return responseData as VibeResponse<T>;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Get the configured base URL.
|
|
768
|
+
*/
|
|
769
|
+
getBaseUrl(): string {
|
|
770
|
+
return this.config.baseUrl;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Make an authenticated request to the Vibe API with smart retry logic.
|
|
775
|
+
*
|
|
776
|
+
* Routing:
|
|
777
|
+
* - All requests route through the IDP proxy (requires VIBE_HMAC_KEY + IDP_URL)
|
|
778
|
+
*
|
|
779
|
+
* PAY-1767: `urlOrPath` is EITHER a full URL or a bare Vibe path ('/v1/collections/...'). Proxy mode
|
|
780
|
+
* signs and forwards the PATH either way, so a caller with only a path (the table delegates) needs no
|
|
781
|
+
* Vibe base URL - VIBE_API_URL is not required to make a proxied call.
|
|
782
|
+
*
|
|
783
|
+
* Retry behavior:
|
|
784
|
+
* - 5xx errors: Retry up to maxRetries times with exponential backoff
|
|
785
|
+
* - 429 errors: Retry once after Retry-After delay (if retryOnRateLimit enabled)
|
|
786
|
+
* - 401/403 errors: No retry (auth errors)
|
|
787
|
+
* - Network errors: Retry with backoff
|
|
788
|
+
*/
|
|
789
|
+
async request<T>(
|
|
790
|
+
urlOrPath: string,
|
|
791
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
792
|
+
body?: unknown,
|
|
793
|
+
authToken?: string
|
|
794
|
+
): Promise<VibeResponse<T>> {
|
|
795
|
+
if (!this.config.clientId) {
|
|
796
|
+
throw new VibeServiceError('VIBE_CLIENT_ID is not configured');
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Check if we should use proxy mode
|
|
800
|
+
if (this.useProxyMode()) {
|
|
801
|
+
if (!this.config.idpUrl) {
|
|
802
|
+
throw new VibeServiceError('IDP_URL is not configured (required for proxy mode)');
|
|
803
|
+
}
|
|
804
|
+
if (!this.config.appSigningKey) {
|
|
805
|
+
throw new VibeServiceError('VIBE_HMAC_KEY is not configured (required for proxy mode)');
|
|
806
|
+
}
|
|
807
|
+
// PAY-1767: a bare path is used as-is; a full URL is reduced to its path+query. Both sign identically.
|
|
808
|
+
const endpoint = urlOrPath.startsWith('/') ? urlOrPath : (() => {
|
|
809
|
+
const u = new URL(urlOrPath);
|
|
810
|
+
return u.pathname + u.search;
|
|
811
|
+
})();
|
|
812
|
+
// Route through IDP proxy
|
|
813
|
+
return this.requestViaProxy<T>(endpoint, method, body, authToken);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// MVP requires proxy mode - direct Vibe access is not supported
|
|
817
|
+
throw new VibeServiceError(
|
|
818
|
+
'Vibe proxy not configured. Set VIBE_HMAC_KEY + IDP_URL for proxy mode.'
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Parse error response body, handling non-JSON responses gracefully.
|
|
824
|
+
*/
|
|
825
|
+
private async parseErrorResponse(response: Response): Promise<VibeErrorResponse> {
|
|
826
|
+
try {
|
|
827
|
+
return await response.json();
|
|
828
|
+
} catch {
|
|
829
|
+
// Non-JSON error response
|
|
830
|
+
const text = await response.text().catch(() => 'Unknown error');
|
|
831
|
+
return {
|
|
832
|
+
success: false,
|
|
833
|
+
error: {
|
|
834
|
+
code: `HTTP_${response.status}`,
|
|
835
|
+
message: text || response.statusText,
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Fetch the schema for type generation.
|
|
843
|
+
*
|
|
844
|
+
* PAY-1767: the old path `/api/v1/vibe_app/schema` has no route. The real per-collection schema route is
|
|
845
|
+
* `/v1/mvp/schemas/{collection}` (Core MvpSchemaController.cs:65). Returns the collection schema DTO.
|
|
846
|
+
*/
|
|
847
|
+
async getSchema(): Promise<Record<string, unknown>> {
|
|
848
|
+
const endpoint = `/v1/mvp/schemas/${this.config.collection}`;
|
|
849
|
+
const response = await this.request<Record<string, unknown>>(endpoint, 'GET');
|
|
850
|
+
return response.data;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// -----------------------------------------------------------------------------
|
|
855
|
+
// SINGLETON INSTANCE
|
|
856
|
+
// -----------------------------------------------------------------------------
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Default Vibe client instance.
|
|
860
|
+
* Uses environment variables for configuration.
|
|
861
|
+
*
|
|
862
|
+
* Usage:
|
|
863
|
+
* import { vibe } from '@payez/next-mvp/vibe'
|
|
864
|
+
* const users = await vibe.users.findMany()
|
|
865
|
+
*/
|
|
866
|
+
export const vibe = new VibeClient();
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Create a new Vibe client with custom configuration.
|
|
870
|
+
*
|
|
871
|
+
* Usage:
|
|
872
|
+
* import { createVibeClient } from '@payez/next-mvp/vibe'
|
|
873
|
+
* const customVibe = createVibeClient({ baseUrl: 'https://custom-api.example.com' })
|
|
874
|
+
*/
|
|
875
|
+
export function createVibeClient(config: VibeClientConfig): VibeClient {
|
|
876
|
+
return new VibeClient(config);
|
|
877
|
+
}
|