@superblocksteam/sdk 2.0.119 → 2.0.120-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/dev-s3-restore.test.mjs +116 -0
- package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +1 -0
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts +6 -0
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.mjs +77 -6
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/client.d.ts +67 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +94 -0
- package/dist/client.js.map +1 -1
- package/dist/dev-utils/dedupe-async.d.ts +16 -0
- package/dist/dev-utils/dedupe-async.d.ts.map +1 -0
- package/dist/dev-utils/dedupe-async.js +27 -0
- package/dist/dev-utils/dedupe-async.js.map +1 -0
- package/dist/dev-utils/dedupe-async.test.d.ts +2 -0
- package/dist/dev-utils/dedupe-async.test.d.ts.map +1 -0
- package/dist/dev-utils/dedupe-async.test.js +120 -0
- package/dist/dev-utils/dedupe-async.test.js.map +1 -0
- package/dist/dev-utils/dev-server-metrics.d.mts +95 -0
- package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -0
- package/dist/dev-utils/dev-server-metrics.mjs +193 -0
- package/dist/dev-utils/dev-server-metrics.mjs.map +1 -0
- package/dist/dev-utils/dev-server-persist.test.mjs +117 -17
- package/dist/dev-utils/dev-server-persist.test.mjs.map +1 -1
- package/dist/dev-utils/dev-server.d.mts +19 -1
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +296 -28
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/dist/flag.d.ts +1 -1
- package/dist/flag.d.ts.map +1 -1
- package/dist/flag.js +3 -3
- package/dist/flag.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/sdk.d.ts +8 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +24 -1
- package/dist/sdk.js.map +1 -1
- package/dist/sdk.test.js +102 -1
- package/dist/sdk.test.js.map +1 -1
- package/dist/telemetry/index.d.ts +2 -1
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +8 -1
- package/dist/telemetry/index.js.map +1 -1
- package/dist/telemetry/logging.d.ts +1 -2
- package/dist/telemetry/logging.d.ts.map +1 -1
- package/dist/telemetry/logging.js +1 -1
- package/dist/telemetry/logging.js.map +1 -1
- package/dist/telemetry/memory-metrics.d.ts +3 -0
- package/dist/telemetry/memory-metrics.d.ts.map +1 -0
- package/dist/telemetry/memory-metrics.js +59 -0
- package/dist/telemetry/memory-metrics.js.map +1 -0
- package/dist/types/common.d.ts +1 -1
- package/dist/types/common.d.ts.map +1 -1
- package/dist/version-control.d.mts.map +1 -1
- package/dist/version-control.mjs +14 -19
- package/dist/version-control.mjs.map +1 -1
- package/eslint.config.js +6 -0
- package/package.json +8 -8
- package/src/cli-replacement/dev-s3-restore.test.mts +156 -0
- package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +1 -0
- package/src/cli-replacement/dev.mts +104 -14
- package/src/client.ts +189 -6
- package/src/dev-utils/dedupe-async.test.ts +151 -0
- package/src/dev-utils/dedupe-async.ts +45 -0
- package/src/dev-utils/dev-server-metrics.mts +252 -0
- package/src/dev-utils/dev-server-persist.test.mts +170 -19
- package/src/dev-utils/dev-server.mts +366 -32
- package/src/flag.ts +4 -4
- package/src/index.ts +19 -1
- package/src/sdk.test.ts +145 -6
- package/src/sdk.ts +36 -0
- package/src/telemetry/index.ts +9 -1
- package/src/telemetry/logging.ts +2 -2
- package/src/telemetry/memory-metrics.ts +90 -0
- package/src/types/common.ts +1 -1
- package/src/version-control.mts +11 -30
- package/test/version-control.test.mts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/turbo.json +1 -0
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
AiServiceFeatureFlags,
|
|
27
27
|
SnapshotManager,
|
|
28
28
|
isSdkApiTemplate,
|
|
29
|
+
stripResolvedFromLockfile,
|
|
29
30
|
} from "@superblocksteam/vite-plugin-file-sync/ai-service";
|
|
30
31
|
import type { DraftInterface } from "@superblocksteam/vite-plugin-file-sync/draft-interface";
|
|
31
32
|
import { createGitService } from "@superblocksteam/vite-plugin-file-sync/git-service";
|
|
@@ -295,7 +296,7 @@ async function installPackages(cwd: string, logger: Logger) {
|
|
|
295
296
|
logger.info("Package installation completed successfully");
|
|
296
297
|
logger.info(stdout);
|
|
297
298
|
} catch (error) {
|
|
298
|
-
logger.error(
|
|
299
|
+
logger.error("Error during package installation", getErrorMeta(error));
|
|
299
300
|
throw error;
|
|
300
301
|
}
|
|
301
302
|
}
|
|
@@ -351,6 +352,13 @@ export async function dev(options: {
|
|
|
351
352
|
/** Pre-existing HTTP server from warm standby mode (avoids port gap on transition). */
|
|
352
353
|
existingServer?: HttpServer;
|
|
353
354
|
|
|
355
|
+
/**
|
|
356
|
+
* Wall-clock timestamp (Date.now()) when the warm-standby /_sb_activate
|
|
357
|
+
* POST was accepted. Forwarded to createDevServer so the warm-activation
|
|
358
|
+
* handoff histogram can be recorded.
|
|
359
|
+
*/
|
|
360
|
+
warmActivationStart?: number;
|
|
361
|
+
|
|
354
362
|
/** Force package installation when the caller has detected dependency drift. */
|
|
355
363
|
forcePackageInstall?: boolean;
|
|
356
364
|
|
|
@@ -383,8 +391,26 @@ export async function dev(options: {
|
|
|
383
391
|
let snapshotManager: SnapshotManager | undefined;
|
|
384
392
|
let gitUserName: string | undefined;
|
|
385
393
|
let gitUserEmail: string | undefined;
|
|
394
|
+
// In-flight install handle. We launch the install while the upload/restart
|
|
395
|
+
// decisions are still being evaluated and join at every gate that depends
|
|
396
|
+
// on post-install state. See the install block and `joinPackageInstall`.
|
|
397
|
+
let packageInstallPromise: Promise<void> | undefined;
|
|
386
398
|
const tracer = getTracer();
|
|
387
399
|
const logger = getLogger(options.logger);
|
|
400
|
+
// Joins the in-flight install at any step that depends on a settled
|
|
401
|
+
// node_modules / lockfile state. Clears the handle so later joins are
|
|
402
|
+
// no-ops. The rejection (if any) propagates so the caller's step can
|
|
403
|
+
// abort cleanly. Defined at outer scope so the post-sync `createDevServer`
|
|
404
|
+
// gate and the abort handler can call it too.
|
|
405
|
+
const joinPackageInstall = async (reason: string): Promise<void> => {
|
|
406
|
+
if (!packageInstallPromise) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
logger.info(`Waiting for background package install (${reason})…`);
|
|
410
|
+
const promise = packageInstallPromise;
|
|
411
|
+
packageInstallPromise = undefined;
|
|
412
|
+
await promise;
|
|
413
|
+
};
|
|
388
414
|
const skipAutoUpgrade = autoUpgradeMode === DevServerAutoUpgradeMode.SKIP;
|
|
389
415
|
const skipCliUpgrade =
|
|
390
416
|
skipAutoUpgrade ||
|
|
@@ -804,6 +830,16 @@ export async function dev(options: {
|
|
|
804
830
|
logger.info("[dev-startup] Skipping download, already in sync");
|
|
805
831
|
}
|
|
806
832
|
|
|
833
|
+
// Unconditional lockfile sanitation: strip cross-registry
|
|
834
|
+
// `resolved` URLs from any lockfile on disk regardless of whether
|
|
835
|
+
// install runs next. The lockfile here is whatever survived the
|
|
836
|
+
// DBFS path (downloadFirst overwrite, prior boot, brownfield
|
|
837
|
+
// import); npm honors `resolved` verbatim and would bypass the
|
|
838
|
+
// active registry. `integrity` is preserved, so genuine
|
|
839
|
+
// cross-registry tarball drift surfaces as EINTEGRITY. No-op when
|
|
840
|
+
// there's no lockfile or no `resolved` entries.
|
|
841
|
+
await stripResolvedFromLockfile(cwd);
|
|
842
|
+
|
|
807
843
|
let hasCliUpdated = false;
|
|
808
844
|
let upgradePromises: Promise<void>[] = [];
|
|
809
845
|
const forceUpgrade =
|
|
@@ -917,17 +953,42 @@ export async function dev(options: {
|
|
|
917
953
|
upgradePromises.length > 0 ||
|
|
918
954
|
forcePackageInstall
|
|
919
955
|
) {
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
956
|
+
// Launch the install while the upload/restart decisions below
|
|
957
|
+
// are still being evaluated. The synchronous joins at upload,
|
|
958
|
+
// CLI restart, and pre-Vite-startup all observe and surface
|
|
959
|
+
// any rejection; the `.catch` backstop only fires if every
|
|
960
|
+
// join was skipped (rare — would mean no upload, no restart,
|
|
961
|
+
// and a re-throw before reaching createDevServer).
|
|
962
|
+
logger.info("Starting package install in background…");
|
|
963
|
+
packageInstallPromise = tracer.startActiveSpan(
|
|
964
|
+
"installPackages",
|
|
965
|
+
async (span) => {
|
|
966
|
+
try {
|
|
967
|
+
// Upgrade global CLI and local packages in parallel - improves performance
|
|
968
|
+
await Promise.all([
|
|
969
|
+
...upgradePromises,
|
|
970
|
+
installPackages(cwd, logger),
|
|
971
|
+
]);
|
|
972
|
+
} finally {
|
|
973
|
+
span.end();
|
|
974
|
+
}
|
|
975
|
+
},
|
|
976
|
+
);
|
|
977
|
+
// Backstop: assigning `.catch` to a separate (discarded) promise
|
|
978
|
+
// keeps `packageInstallPromise` itself rejecting, so the joins
|
|
979
|
+
// can still observe and abort. Without this, an install that
|
|
980
|
+
// fails before any join fires becomes an unhandled rejection.
|
|
981
|
+
const installApplicationId = applicationConfig.id;
|
|
982
|
+
const installUpgradeCount = upgradePromises.length;
|
|
983
|
+
packageInstallPromise.catch((err) => {
|
|
984
|
+
logger.error(
|
|
985
|
+
// errorId is encoded into the message body because the
|
|
986
|
+
// logger.error contract limits structured attributes to
|
|
987
|
+
// `{ error: { kind, message, stack } }`. The id stays
|
|
988
|
+
// grep-able for Datadog/Sentry alert rules.
|
|
989
|
+
`Background package install failed [errorId=DEV_SERVER_BG_INSTALL_FAILED applicationId=${installApplicationId} cwd=${cwd} upgradePromiseCount=${installUpgradeCount}]`,
|
|
990
|
+
getErrorMeta(err),
|
|
991
|
+
);
|
|
931
992
|
});
|
|
932
993
|
} else {
|
|
933
994
|
logger.info(
|
|
@@ -938,6 +999,9 @@ export async function dev(options: {
|
|
|
938
999
|
const shouldUploadPackageState =
|
|
939
1000
|
hasPackageChanged || forcePackageInstall;
|
|
940
1001
|
if (shouldUploadPackageState || uploadFirst) {
|
|
1002
|
+
// Upload serializes the post-install lockfile + node_modules
|
|
1003
|
+
// tree to DBFS, so it must observe a quiesced install.
|
|
1004
|
+
await joinPackageInstall("before upload");
|
|
941
1005
|
logger.info(
|
|
942
1006
|
`Uploading local files to branch '${activeDbfsBranchName}' on server before starting`,
|
|
943
1007
|
);
|
|
@@ -952,6 +1016,9 @@ export async function dev(options: {
|
|
|
952
1016
|
}
|
|
953
1017
|
|
|
954
1018
|
if (hasCliUpdated) {
|
|
1019
|
+
// Exiting mid-install would leave a half-written lockfile that
|
|
1020
|
+
// the next boot would have to recover from.
|
|
1021
|
+
await joinPackageInstall("before CLI restart");
|
|
955
1022
|
try {
|
|
956
1023
|
logger.info("Releasing lock before restarting the dev server");
|
|
957
1024
|
await aiService?.removeIntegrationCache();
|
|
@@ -967,9 +1034,9 @@ export async function dev(options: {
|
|
|
967
1034
|
}
|
|
968
1035
|
});
|
|
969
1036
|
} catch (error: any) {
|
|
970
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
971
1037
|
logger.error(
|
|
972
|
-
|
|
1038
|
+
"[dev-server] Startup failed during sync/lock/setup (exiting with code 1)",
|
|
1039
|
+
getErrorMeta(error),
|
|
973
1040
|
);
|
|
974
1041
|
try {
|
|
975
1042
|
await aiService?.removeIntegrationCache();
|
|
@@ -983,6 +1050,15 @@ export async function dev(options: {
|
|
|
983
1050
|
logger.info("Skipping directory sync");
|
|
984
1051
|
}
|
|
985
1052
|
|
|
1053
|
+
// Drain the install before Vite starts. Vite's createServer eagerly
|
|
1054
|
+
// scans `node_modules` for `optimizeDeps` pre-bundling; if that scan
|
|
1055
|
+
// observes a mid-install state (npm moves per-package directories via
|
|
1056
|
+
// rename, so a reader can race the rename), the resulting `.vite/deps`
|
|
1057
|
+
// cache embeds partial state that survives across reloads. Awaiting
|
|
1058
|
+
// here costs at most the install's remaining wall time — the upload
|
|
1059
|
+
// and CLI-restart joins above usually drain it first.
|
|
1060
|
+
await joinPackageInstall("before Vite startup");
|
|
1061
|
+
|
|
986
1062
|
const activateRuntimeGitService = async (): Promise<
|
|
987
1063
|
GitService | undefined
|
|
988
1064
|
> => {
|
|
@@ -1098,6 +1174,7 @@ export async function dev(options: {
|
|
|
1098
1174
|
sdk,
|
|
1099
1175
|
superblocksBaseUrl: tokenConfig.superblocksBaseUrl,
|
|
1100
1176
|
existingServer: options.existingServer,
|
|
1177
|
+
warmActivationStart: options.warmActivationStart,
|
|
1101
1178
|
// TODO: Remove this cast — build the options object to match
|
|
1102
1179
|
// CreateDevServerOptions directly so new required fields cause a
|
|
1103
1180
|
// compile error instead of silently passing undefined.
|
|
@@ -1117,6 +1194,19 @@ export async function dev(options: {
|
|
|
1117
1194
|
logger.warn(`Error stopping auth hot-reload server: ${error}`);
|
|
1118
1195
|
});
|
|
1119
1196
|
}
|
|
1197
|
+
// Drain any straggler install before tear-down. By this point the
|
|
1198
|
+
// pre-Vite join has already run on the success path, so usually
|
|
1199
|
+
// `packageInstallPromise` is undefined and this is a no-op; if abort
|
|
1200
|
+
// races createDevServer (or fires during the inner sync block on
|
|
1201
|
+
// some error path), draining here keeps the spawned npm child from
|
|
1202
|
+
// being SIGKILL'd mid-rename. Errors only get logged — we are tearing
|
|
1203
|
+
// down anyway.
|
|
1204
|
+
joinPackageInstall("during abort").catch((error: unknown) => {
|
|
1205
|
+
logger.warn(
|
|
1206
|
+
"Error draining background install during abort",
|
|
1207
|
+
getErrorMeta(error),
|
|
1208
|
+
);
|
|
1209
|
+
});
|
|
1120
1210
|
// Clean up AI service cache (must happen before app switches directories)
|
|
1121
1211
|
aiService?.removeIntegrationCache().catch((error: any) => {
|
|
1122
1212
|
logger.warn(`Error removing integration cache: ${error}`);
|
package/src/client.ts
CHANGED
|
@@ -2796,21 +2796,71 @@ export interface UsageRecordRow {
|
|
|
2796
2796
|
source: string;
|
|
2797
2797
|
}
|
|
2798
2798
|
|
|
2799
|
+
export interface UserCreditLimit {
|
|
2800
|
+
amountLimitCents: number | null;
|
|
2801
|
+
creditLimit: number | null;
|
|
2802
|
+
id: string;
|
|
2803
|
+
orgId: string;
|
|
2804
|
+
updatedAt: string;
|
|
2805
|
+
updatedBy: string | null;
|
|
2806
|
+
userId: string | null;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
export type OrgCreditLimit = Omit<UserCreditLimit, "userId">;
|
|
2810
|
+
|
|
2811
|
+
export interface BillingUsageLimitsResponse {
|
|
2812
|
+
limits: UserCreditLimit[];
|
|
2813
|
+
orgLimit: OrgCreditLimit | null;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
type BillingUsageLimitValue =
|
|
2817
|
+
| {
|
|
2818
|
+
amountLimitCents: number;
|
|
2819
|
+
creditLimit?: never;
|
|
2820
|
+
}
|
|
2821
|
+
| {
|
|
2822
|
+
amountLimitCents?: never;
|
|
2823
|
+
creditLimit: number;
|
|
2824
|
+
};
|
|
2825
|
+
|
|
2826
|
+
export type UpdateUserBillingUsageLimitRequest = BillingUsageLimitValue & {
|
|
2827
|
+
userId: string | null;
|
|
2828
|
+
};
|
|
2829
|
+
|
|
2830
|
+
export type UpdateOrgBillingUsageLimitRequest = BillingUsageLimitValue;
|
|
2831
|
+
|
|
2799
2832
|
export interface PlanSummary {
|
|
2800
2833
|
billingInterval: "annual" | "monthly";
|
|
2834
|
+
builderSeatsAssigned?: number;
|
|
2835
|
+
builderSeatsTotal?: number;
|
|
2836
|
+
contractEnd?: string | null;
|
|
2837
|
+
contractStart?: string | null;
|
|
2838
|
+
contractType?: string | null;
|
|
2801
2839
|
currentPlanCredits: number;
|
|
2802
2840
|
currentUsageCredits: number;
|
|
2803
2841
|
cycleStart: string | null;
|
|
2804
2842
|
cycleEnd: string | null;
|
|
2805
2843
|
creditsPerSeat: number | null;
|
|
2806
|
-
pricePerSeatAnnual: number | null;
|
|
2807
|
-
pricePerSeatMonthly: number | null;
|
|
2808
|
-
deployedAppsUsed: number;
|
|
2809
|
-
deployedAppsLimit: number;
|
|
2810
|
-
deployedAppsIncluded: number;
|
|
2811
2844
|
deployedAppsAdditional: number;
|
|
2812
|
-
deployedAppPriceMonthly: number | null;
|
|
2813
2845
|
deployedAppPriceAnnual: number | null;
|
|
2846
|
+
deployedAppPriceMonthly: number | null;
|
|
2847
|
+
deployedAppsIncluded: number;
|
|
2848
|
+
deployedAppsLimit: number;
|
|
2849
|
+
deployedAppsUsed: number;
|
|
2850
|
+
deployedAppCostCents?: number | null;
|
|
2851
|
+
dollarCommitAmountCents?: number | null;
|
|
2852
|
+
dollarCommitUsedCents?: number | null;
|
|
2853
|
+
overageAppsEnabled?: boolean;
|
|
2854
|
+
overageCreditsEnabled?: boolean;
|
|
2855
|
+
overageSeatsEnabled?: boolean;
|
|
2856
|
+
pricePerSeatAnnual: number | null;
|
|
2857
|
+
pricePerSeatMonthly: number | null;
|
|
2858
|
+
seatsUnlimited?: boolean;
|
|
2859
|
+
subscriptionStatus: string | null;
|
|
2860
|
+
topupCreditsPurchased: number;
|
|
2861
|
+
topupCreditsUsed: number;
|
|
2862
|
+
usageResetPeriodLimit: number;
|
|
2863
|
+
usageResetPeriodType: "annual" | "contract_term" | "monthly";
|
|
2814
2864
|
}
|
|
2815
2865
|
|
|
2816
2866
|
export async function fetchBillingUsageDaily({
|
|
@@ -2945,6 +2995,139 @@ export async function fetchBillingPlanSummary({
|
|
|
2945
2995
|
}
|
|
2946
2996
|
}
|
|
2947
2997
|
|
|
2998
|
+
export async function fetchBillingUsageLimits({
|
|
2999
|
+
cliVersion,
|
|
3000
|
+
token,
|
|
3001
|
+
superblocksBaseUrl,
|
|
3002
|
+
}: {
|
|
3003
|
+
cliVersion: string;
|
|
3004
|
+
token: string;
|
|
3005
|
+
superblocksBaseUrl: string;
|
|
3006
|
+
}): Promise<BillingUsageLimitsResponse> {
|
|
3007
|
+
try {
|
|
3008
|
+
const url = new URL(
|
|
3009
|
+
`${BASE_SERVER_API_URL_V1}/billing/user-credit-limits`,
|
|
3010
|
+
superblocksBaseUrl,
|
|
3011
|
+
);
|
|
3012
|
+
|
|
3013
|
+
const config: AxiosRequestConfig = {
|
|
3014
|
+
method: "get",
|
|
3015
|
+
url: url.toString(),
|
|
3016
|
+
headers: {
|
|
3017
|
+
Authorization: "Bearer " + token,
|
|
3018
|
+
[CLI_VERSION_HEADER]: cliVersion,
|
|
3019
|
+
},
|
|
3020
|
+
};
|
|
3021
|
+
const response = await axios(config);
|
|
3022
|
+
return response.data.data as BillingUsageLimitsResponse;
|
|
3023
|
+
} catch (e: any) {
|
|
3024
|
+
let message: string;
|
|
3025
|
+
if (e instanceof AxiosError) {
|
|
3026
|
+
message =
|
|
3027
|
+
(e.response?.data?.responseMeta?.message as string) ??
|
|
3028
|
+
JSON.stringify(e.response?.data) ??
|
|
3029
|
+
e.response?.statusText ??
|
|
3030
|
+
e?.message;
|
|
3031
|
+
} else {
|
|
3032
|
+
message = `${e?.message ? e?.message : e}`;
|
|
3033
|
+
}
|
|
3034
|
+
throw new Error(`Could not fetch billing usage limits: ${message}`);
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
export async function updateUserBillingUsageLimit({
|
|
3039
|
+
amountLimitCents,
|
|
3040
|
+
cliVersion,
|
|
3041
|
+
creditLimit,
|
|
3042
|
+
token,
|
|
3043
|
+
superblocksBaseUrl,
|
|
3044
|
+
userId,
|
|
3045
|
+
}: UpdateUserBillingUsageLimitRequest & {
|
|
3046
|
+
cliVersion: string;
|
|
3047
|
+
token: string;
|
|
3048
|
+
superblocksBaseUrl: string;
|
|
3049
|
+
}): Promise<{ success: boolean }> {
|
|
3050
|
+
try {
|
|
3051
|
+
const url = new URL(
|
|
3052
|
+
`${BASE_SERVER_API_URL_V1}/billing/user-credit-limits`,
|
|
3053
|
+
superblocksBaseUrl,
|
|
3054
|
+
);
|
|
3055
|
+
|
|
3056
|
+
const config: AxiosRequestConfig = {
|
|
3057
|
+
method: "put",
|
|
3058
|
+
url: url.toString(),
|
|
3059
|
+
headers: {
|
|
3060
|
+
Authorization: "Bearer " + token,
|
|
3061
|
+
[CLI_VERSION_HEADER]: cliVersion,
|
|
3062
|
+
},
|
|
3063
|
+
data: {
|
|
3064
|
+
userId,
|
|
3065
|
+
...(amountLimitCents !== undefined
|
|
3066
|
+
? { amountLimitCents }
|
|
3067
|
+
: { creditLimit }),
|
|
3068
|
+
},
|
|
3069
|
+
};
|
|
3070
|
+
const response = await axios(config);
|
|
3071
|
+
return response.data.data as { success: boolean };
|
|
3072
|
+
} catch (e: any) {
|
|
3073
|
+
let message: string;
|
|
3074
|
+
if (e instanceof AxiosError) {
|
|
3075
|
+
message =
|
|
3076
|
+
(e.response?.data?.responseMeta?.message as string) ??
|
|
3077
|
+
JSON.stringify(e.response?.data) ??
|
|
3078
|
+
e.response?.statusText ??
|
|
3079
|
+
e?.message;
|
|
3080
|
+
} else {
|
|
3081
|
+
message = `${e?.message ? e?.message : e}`;
|
|
3082
|
+
}
|
|
3083
|
+
throw new Error(`Could not update user billing usage limit: ${message}`);
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
export async function updateOrgBillingUsageLimit({
|
|
3088
|
+
amountLimitCents,
|
|
3089
|
+
cliVersion,
|
|
3090
|
+
creditLimit,
|
|
3091
|
+
token,
|
|
3092
|
+
superblocksBaseUrl,
|
|
3093
|
+
}: UpdateOrgBillingUsageLimitRequest & {
|
|
3094
|
+
cliVersion: string;
|
|
3095
|
+
token: string;
|
|
3096
|
+
superblocksBaseUrl: string;
|
|
3097
|
+
}): Promise<{ success: boolean }> {
|
|
3098
|
+
try {
|
|
3099
|
+
const url = new URL(
|
|
3100
|
+
`${BASE_SERVER_API_URL_V1}/billing/user-credit-limits/org`,
|
|
3101
|
+
superblocksBaseUrl,
|
|
3102
|
+
);
|
|
3103
|
+
|
|
3104
|
+
const config: AxiosRequestConfig = {
|
|
3105
|
+
method: "put",
|
|
3106
|
+
url: url.toString(),
|
|
3107
|
+
headers: {
|
|
3108
|
+
Authorization: "Bearer " + token,
|
|
3109
|
+
[CLI_VERSION_HEADER]: cliVersion,
|
|
3110
|
+
},
|
|
3111
|
+
data:
|
|
3112
|
+
amountLimitCents !== undefined ? { amountLimitCents } : { creditLimit },
|
|
3113
|
+
};
|
|
3114
|
+
const response = await axios(config);
|
|
3115
|
+
return response.data.data as { success: boolean };
|
|
3116
|
+
} catch (e: any) {
|
|
3117
|
+
let message: string;
|
|
3118
|
+
if (e instanceof AxiosError) {
|
|
3119
|
+
message =
|
|
3120
|
+
(e.response?.data?.responseMeta?.message as string) ??
|
|
3121
|
+
JSON.stringify(e.response?.data) ??
|
|
3122
|
+
e.response?.statusText ??
|
|
3123
|
+
e?.message;
|
|
3124
|
+
} else {
|
|
3125
|
+
message = `${e?.message ? e?.message : e}`;
|
|
3126
|
+
}
|
|
3127
|
+
throw new Error(`Could not update org billing usage limit: ${message}`);
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
|
|
2948
3131
|
// ---------------------------------------------------------------------------
|
|
2949
3132
|
// Knowledge (Facts)
|
|
2950
3133
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Regression tests for the in-flight dedupe used by the dev-server graceful
|
|
2
|
+
// shutdown path (hq-o55a). The bug being guarded against: multiple signals
|
|
3
|
+
// (SIGINT, SIGTERM, SIGABRT, uncaughtException) fire in rapid succession during
|
|
4
|
+
// shutdown and each handler re-runs cleanup against already-freed native state,
|
|
5
|
+
// surfacing as `double free or corruption (fasttop)` from glibc.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { dedupeAsync } from "./dedupe-async.js";
|
|
10
|
+
|
|
11
|
+
describe("dedupeAsync", () => {
|
|
12
|
+
it("invokes the wrapped function exactly once across concurrent calls", async () => {
|
|
13
|
+
const inner = vi.fn(async () => "ok");
|
|
14
|
+
const wrapped = dedupeAsync(inner);
|
|
15
|
+
|
|
16
|
+
const results = await Promise.all([
|
|
17
|
+
wrapped(),
|
|
18
|
+
wrapped(),
|
|
19
|
+
wrapped(),
|
|
20
|
+
wrapped(),
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
expect(inner).toHaveBeenCalledTimes(1);
|
|
24
|
+
expect(results).toEqual(["ok", "ok", "ok", "ok"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns the same promise instance to all concurrent callers", () => {
|
|
28
|
+
const inner = vi.fn(async () => 42);
|
|
29
|
+
const wrapped = dedupeAsync(inner);
|
|
30
|
+
|
|
31
|
+
const p1 = wrapped();
|
|
32
|
+
const p2 = wrapped();
|
|
33
|
+
|
|
34
|
+
expect(p1).toBe(p2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("invokes onDuplicate for each call after the first", async () => {
|
|
38
|
+
let resolveInner: (v: string) => void = () => {};
|
|
39
|
+
const inner = vi.fn(
|
|
40
|
+
() =>
|
|
41
|
+
new Promise<string>((resolve) => {
|
|
42
|
+
resolveInner = resolve;
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
const onDuplicate = vi.fn();
|
|
46
|
+
const wrapped = dedupeAsync(inner, { onDuplicate });
|
|
47
|
+
|
|
48
|
+
const p1 = wrapped();
|
|
49
|
+
wrapped();
|
|
50
|
+
wrapped();
|
|
51
|
+
wrapped();
|
|
52
|
+
|
|
53
|
+
expect(onDuplicate).toHaveBeenCalledTimes(3);
|
|
54
|
+
expect(inner).toHaveBeenCalledTimes(1);
|
|
55
|
+
|
|
56
|
+
resolveInner("done");
|
|
57
|
+
await expect(p1).resolves.toBe("done");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("retains the cached promise after the inner call settles — post-settle callers get the same outcome", async () => {
|
|
61
|
+
const inner = vi.fn(async () => "shutdown-complete");
|
|
62
|
+
const wrapped = dedupeAsync(inner);
|
|
63
|
+
|
|
64
|
+
await expect(wrapped()).resolves.toBe("shutdown-complete");
|
|
65
|
+
// Second invocation AFTER the first settled — must NOT re-trigger.
|
|
66
|
+
await expect(wrapped()).resolves.toBe("shutdown-complete");
|
|
67
|
+
expect(inner).toHaveBeenCalledTimes(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("propagates rejection to all callers without re-running the inner function", async () => {
|
|
71
|
+
const inner = vi.fn(async () => {
|
|
72
|
+
throw new Error("boom");
|
|
73
|
+
});
|
|
74
|
+
const wrapped = dedupeAsync(inner);
|
|
75
|
+
|
|
76
|
+
const p1 = wrapped();
|
|
77
|
+
const p2 = wrapped();
|
|
78
|
+
const p3 = wrapped();
|
|
79
|
+
|
|
80
|
+
await expect(p1).rejects.toThrow(/boom/);
|
|
81
|
+
await expect(p2).rejects.toThrow(/boom/);
|
|
82
|
+
await expect(p3).rejects.toThrow(/boom/);
|
|
83
|
+
expect(inner).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("retains the cached rejection after the inner call settles — post-settle callers get the same rejection without re-run", async () => {
|
|
87
|
+
// Guards against a future change that resets `inFlight = null` on
|
|
88
|
+
// rejection (a plausible "retry on failure" tweak). The shutdown
|
|
89
|
+
// contract is that BOTH outcomes are sticky — resolve and reject —
|
|
90
|
+
// because re-entering cleanup after a failed first attempt is what
|
|
91
|
+
// caused the original double-free. A passing test for the resolve path
|
|
92
|
+
// alone would let that regression slip through.
|
|
93
|
+
const inner = vi.fn(async () => {
|
|
94
|
+
throw new Error("boom");
|
|
95
|
+
});
|
|
96
|
+
const wrapped = dedupeAsync(inner);
|
|
97
|
+
|
|
98
|
+
await expect(wrapped()).rejects.toThrow(/boom/);
|
|
99
|
+
// Second invocation AFTER the first rejection settled — must NOT
|
|
100
|
+
// re-trigger and must surface the same rejection.
|
|
101
|
+
await expect(wrapped()).rejects.toThrow(/boom/);
|
|
102
|
+
expect(inner).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("forwards args to the inner function on the first call only", async () => {
|
|
106
|
+
const inner = vi.fn(async (a: number, b: string) => `${a}-${b}`);
|
|
107
|
+
const wrapped = dedupeAsync(inner);
|
|
108
|
+
|
|
109
|
+
const p1 = wrapped(1, "first");
|
|
110
|
+
// Second call passes different args, but is short-circuited — inner
|
|
111
|
+
// should NOT be invoked again. The dedupe is intentionally
|
|
112
|
+
// input-agnostic: shutdown semantics don't depend on the second
|
|
113
|
+
// signal's arguments.
|
|
114
|
+
const p2 = wrapped(2, "second");
|
|
115
|
+
|
|
116
|
+
await expect(p1).resolves.toBe("1-first");
|
|
117
|
+
await expect(p2).resolves.toBe("1-first");
|
|
118
|
+
expect(inner).toHaveBeenCalledTimes(1);
|
|
119
|
+
expect(inner).toHaveBeenCalledWith(1, "first");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("passes the duplicate caller's args to onDuplicate (for source-tagged logging)", async () => {
|
|
123
|
+
// The dev-server graceful shutdown threads a `source` field through
|
|
124
|
+
// (signal name, "uncaughtException", "/_sb_disconnect") so the
|
|
125
|
+
// duplicate-suppression log can tell apart "4 OS signals fired" from
|
|
126
|
+
// "1 signal fired and cleanup panicked 3 times". The dedupe must
|
|
127
|
+
// forward each duplicate's own args, not the first caller's.
|
|
128
|
+
let resolveInner: () => void = () => {};
|
|
129
|
+
const inner = vi.fn(
|
|
130
|
+
(arg: { source: string }): Promise<{ source: string }> =>
|
|
131
|
+
new Promise((resolve) => {
|
|
132
|
+
resolveInner = () => resolve(arg);
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
const onDuplicate = vi.fn();
|
|
136
|
+
const wrapped = dedupeAsync(inner, { onDuplicate });
|
|
137
|
+
|
|
138
|
+
const p1 = wrapped({ source: "SIGTERM" });
|
|
139
|
+
wrapped({ source: "SIGINT" });
|
|
140
|
+
wrapped({ source: "SIGABRT" });
|
|
141
|
+
|
|
142
|
+
expect(onDuplicate).toHaveBeenCalledTimes(2);
|
|
143
|
+
expect(onDuplicate).toHaveBeenNthCalledWith(1, { source: "SIGINT" });
|
|
144
|
+
expect(onDuplicate).toHaveBeenNthCalledWith(2, { source: "SIGABRT" });
|
|
145
|
+
expect(inner).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(inner).toHaveBeenCalledWith({ source: "SIGTERM" });
|
|
147
|
+
|
|
148
|
+
resolveInner();
|
|
149
|
+
await p1;
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// In-flight call deduper. Wraps an async function so that concurrent invocations
|
|
2
|
+
// share the same promise — subsequent callers get the original in-flight result
|
|
3
|
+
// instead of triggering another execution.
|
|
4
|
+
//
|
|
5
|
+
// After the wrapped function settles (resolves OR rejects) the cached promise is
|
|
6
|
+
// retained so all post-settle callers receive the same outcome. There is no
|
|
7
|
+
// reset path: callers that want to "run again later" should use a fresh
|
|
8
|
+
// `dedupeAsync` instance.
|
|
9
|
+
//
|
|
10
|
+
// Used by the dev-server graceful shutdown path (hq-o55a). Multiple signals
|
|
11
|
+
// (SIGINT, SIGTERM, SIGABRT, uncaughtException) can fire in rapid succession
|
|
12
|
+
// during shutdown. Without a dedupe, every handler re-runs lockService /
|
|
13
|
+
// vite / httpServer cleanup against already-freed native state, which surfaces
|
|
14
|
+
// as `double free or corruption (fasttop)` from glibc and SIGABRT killing the
|
|
15
|
+
// process before it can exit cleanly.
|
|
16
|
+
|
|
17
|
+
export interface DedupeAsyncOptions<TArgs extends unknown[]> {
|
|
18
|
+
/**
|
|
19
|
+
* Optional callback invoked when a duplicate call is detected. The first
|
|
20
|
+
* caller's invocation runs normally; this fires for the 2nd, 3rd, …
|
|
21
|
+
* Useful for logging "shutdown already in progress; ignoring duplicate
|
|
22
|
+
* signal".
|
|
23
|
+
*
|
|
24
|
+
* Receives the *duplicate* call's args (not the first call's). The first
|
|
25
|
+
* call's args are what the wrapped function actually ran with — duplicates
|
|
26
|
+
* are intentionally short-circuited and their args are dropped from
|
|
27
|
+
* execution but surfaced here for logging.
|
|
28
|
+
*/
|
|
29
|
+
onDuplicate?: (...args: TArgs) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function dedupeAsync<TArgs extends unknown[], TResult>(
|
|
33
|
+
fn: (...args: TArgs) => Promise<TResult>,
|
|
34
|
+
options: DedupeAsyncOptions<TArgs> = {},
|
|
35
|
+
): (...args: TArgs) => Promise<TResult> {
|
|
36
|
+
let inFlight: Promise<TResult> | null = null;
|
|
37
|
+
return (...args: TArgs) => {
|
|
38
|
+
if (inFlight) {
|
|
39
|
+
options.onDuplicate?.(...args);
|
|
40
|
+
return inFlight;
|
|
41
|
+
}
|
|
42
|
+
inFlight = fn(...args);
|
|
43
|
+
return inFlight;
|
|
44
|
+
};
|
|
45
|
+
}
|